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
+55 -26
View File
@@ -472,6 +472,12 @@ const getPageLayoutStyles = (
}
img.has-text-siblings {
${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;
}
: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) => {
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');
if (widthAttr && (widthAttr.endsWith('%') || widthAttr.endsWith('vw'))) {
const percentage = parseFloat(widthAttr);
if (!isNaN(percentage)) {
img.style.width = `${(percentage / 100) * window.innerWidth}px`;
img.removeAttribute('width');
}
}
const percentWidth =
widthAttr && (widthAttr.endsWith('%') || widthAttr.endsWith('vw'))
? parseFloat(widthAttr)
: NaN;
const heightAttr = img.getAttribute('height');
if (heightAttr && (heightAttr.endsWith('%') || heightAttr.endsWith('vh'))) {
const percentage = parseFloat(heightAttr);
if (!isNaN(percentage)) {
img.style.height = `${(percentage / 100) * window.innerHeight}px`;
img.removeAttribute('height');
const percentHeight =
heightAttr && (heightAttr.endsWith('%') || heightAttr.endsWith('vh'))
? parseFloat(heightAttr)
: NaN;
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';
}
}
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');
}
return { img, percentWidth, percentHeight, inlineWithText, keepBaseline };
});
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) => {
const computedStyle = window.getComputedStyle(hr);
if (computedStyle.backgroundImage && computedStyle.backgroundImage !== 'none') {