fix(layout): respect author vertical-align on inline images (#4866) (#4878)

img.has-text-siblings forced vertical-align: baseline on every inline
image with text siblings, out-specifying a book's own value (for example
a CJK glyph-substitution image nudged with vertical-align: -0.15em).
Because baseline is the CSS initial value, the declaration only ever
mattered when it clobbered an authored value.

Keep baseline as a default only: move it to a new
has-text-siblings-baseline class that applyImageStyle adds only when the
image has no author-set vertical-align (detected via getComputedStyle).
Refactor applyImageStyle to a two-phase read-then-write pass to avoid a
getComputedStyle-after-write style recalc.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-02 00:19:20 +09:00
committed by GitHub
parent 3ac1a1a45b
commit 4d0be496b2
2 changed files with 99 additions and 26 deletions
@@ -417,6 +417,33 @@ describe('getStyles', () => {
expect(css).toContain('background-color: #ffffff !important'); expect(css).toContain('background-color: #ffffff !important');
expect(css).toContain('color: #171717 !important'); expect(css).toContain('color: #171717 !important');
}); });
it('scopes the inline-image baseline default to its own class so author values win #4866', () => {
const vs = makeViewSettings();
const themeCode: ThemeCode = {
bg: '#ffffff',
fg: '#171717',
primary: '#0066cc',
palette: {
'base-100': '#ffffff',
'base-200': '#f2f2f2',
'base-300': '#e0e0e0',
'base-content': '#171717',
neutral: '#cccccc',
'neutral-content': '#444444',
primary: '#0066cc',
secondary: '#3399ff',
accent: '#0055aa',
},
isDarkMode: false,
};
const css = getStyles(vs, themeCode);
// Baseline is opt-in via a dedicated class, not forced on every inline image.
expect(css).toMatch(/img\.has-text-siblings-baseline\s*\{[^}]*vertical-align:\s*baseline/);
// The size-clamp rule must not carry vertical-align anymore.
const clampRule = css.match(/\n\s*img\.has-text-siblings\s*\{[^}]*\}/)![0];
expect(clampRule).not.toContain('vertical-align');
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -479,6 +506,23 @@ describe('applyImageStyle', () => {
const img = document.querySelector('img')!; const img = document.querySelector('img')!;
expect(img.classList.contains('has-text-siblings')).toBe(false); expect(img.classList.contains('has-text-siblings')).toBe(false);
}); });
it('applies the baseline default when the image has no author vertical-align', () => {
document.body.innerHTML = '<p>Some text <img src="test.png" /> more text</p>';
applyImageStyle(document);
const img = document.querySelector('img')!;
expect(img.classList.contains('has-text-siblings')).toBe(true);
expect(img.classList.contains('has-text-siblings-baseline')).toBe(true);
});
it('respects an author-set vertical-align (no baseline default) #4866', () => {
document.body.innerHTML =
'<p>Some text <img src="test.png" style="vertical-align: -0.15em" /> more text</p>';
applyImageStyle(document);
const img = document.querySelector('img')!;
expect(img.classList.contains('has-text-siblings')).toBe(true);
expect(img.classList.contains('has-text-siblings-baseline')).toBe(false);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+55 -26
View File
@@ -472,6 +472,12 @@ const getPageLayoutStyles = (
} }
img.has-text-siblings { img.has-text-siblings {
${vertical ? 'width: 1em;' : 'height: 1em;'} ${vertical ? 'width: 1em;' : 'height: 1em;'}
}
/* Baseline is only Readest's default: applyImageStyle adds this class when the
book leaves vertical-align at its initial value, so an author-set value
(e.g. a CJK glyph-substitution image nudged with vertical-align: -0.15em)
keeps winning. See #4866. */
img.has-text-siblings-baseline {
vertical-align: baseline; vertical-align: baseline;
} }
:is(div) > img.has-text-siblings[style*="object-fit"] { :is(div) > img.has-text-siblings[style*="object-fit"] {
@@ -1148,37 +1154,60 @@ export const applyScrollbarStyle = (document: Document, hideScrollbar: boolean)
}; };
export const applyImageStyle = (document: Document) => { export const applyImageStyle = (document: Document) => {
document.querySelectorAll('img').forEach((img) => { const win = document.defaultView ?? window;
// Two-phase (read then write): reading getComputedStyle after a class/style
// write forces a style recalc, so gather every decision first and apply the
// mutations afterwards (same reasoning as keepTextAlignment's split).
const plans = Array.from(document.querySelectorAll('img')).map((img) => {
const widthAttr = img.getAttribute('width'); const widthAttr = img.getAttribute('width');
if (widthAttr && (widthAttr.endsWith('%') || widthAttr.endsWith('vw'))) { const percentWidth =
const percentage = parseFloat(widthAttr); widthAttr && (widthAttr.endsWith('%') || widthAttr.endsWith('vw'))
if (!isNaN(percentage)) { ? parseFloat(widthAttr)
img.style.width = `${(percentage / 100) * window.innerWidth}px`; : NaN;
img.removeAttribute('width');
}
}
const heightAttr = img.getAttribute('height'); const heightAttr = img.getAttribute('height');
if (heightAttr && (heightAttr.endsWith('%') || heightAttr.endsWith('vh'))) { const percentHeight =
const percentage = parseFloat(heightAttr); heightAttr && (heightAttr.endsWith('%') || heightAttr.endsWith('vh'))
if (!isNaN(percentage)) { ? parseFloat(heightAttr)
img.style.height = `${(percentage / 100) * window.innerHeight}px`; : NaN;
img.removeAttribute('height');
let inlineWithText = false;
let keepBaseline = false;
const parent = img.parentNode;
if (parent && parent.nodeType === Node.ELEMENT_NODE) {
const childNodes = Array.from(parent.childNodes);
const hasTextSiblings = childNodes.some(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
);
const isInline = childNodes.every(
(node) => node.nodeType !== Node.ELEMENT_NODE || (node as Element).tagName !== 'BR',
);
inlineWithText = hasTextSiblings && isInline;
if (inlineWithText) {
// Only supply Readest's baseline default when the book leaves
// vertical-align at its initial value; an author-set value (e.g. a CJK
// glyph-substitution image nudged with `vertical-align: -0.15em`) must
// win. Empty string covers environments that report unset props as ''.
const valign = win.getComputedStyle(img).verticalAlign;
keepBaseline = valign === '' || valign === 'baseline';
} }
} }
return { img, percentWidth, percentHeight, inlineWithText, keepBaseline };
const parent = img.parentNode;
if (!parent || parent.nodeType !== Node.ELEMENT_NODE) return;
const hasTextSiblings = Array.from(parent.childNodes).some(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
);
const isInline = Array.from(parent.childNodes).every(
(node) => node.nodeType !== Node.ELEMENT_NODE || (node as Element).tagName !== 'BR',
);
if (hasTextSiblings && isInline) {
img.classList.add('has-text-siblings');
}
}); });
for (const { img, percentWidth, percentHeight, inlineWithText, keepBaseline } of plans) {
if (!isNaN(percentWidth)) {
img.style.width = `${(percentWidth / 100) * window.innerWidth}px`;
img.removeAttribute('width');
}
if (!isNaN(percentHeight)) {
img.style.height = `${(percentHeight / 100) * window.innerHeight}px`;
img.removeAttribute('height');
}
if (inlineWithText) {
img.classList.add('has-text-siblings');
if (keepBaseline) img.classList.add('has-text-siblings-baseline');
}
}
document.querySelectorAll('hr').forEach((hr) => { document.querySelectorAll('hr').forEach((hr) => {
const computedStyle = window.getComputedStyle(hr); const computedStyle = window.getComputedStyle(hr);
if (computedStyle.backgroundImage && computedStyle.backgroundImage !== 'none') { if (computedStyle.backgroundImage && computedStyle.backgroundImage !== 'none') {