Skip to content

Commit 17aafc9

Browse files
committed
feat(epub): inline images and floats (T-66)
Inline <img> is no longer dropped: an image with computed inline display (the CSS default) flows on the line as a single unbreakable token, sized from CSS width/height, the HTML attributes, or its intrinsic pixel size at 0.75pt/px, capped to the content width. It advances the pen, sits bottom-on-baseline, grows the line's ascent to its height, rides PositionedLine.images as PlacedImage (draw width kept separate from the justification-stretched advance), and paints next to the run loop for both raster and SVG payloads. display:block or floated images keep the block ImageBox path. float:left/right leaves the flow, lays out against the content edge, and registers an exclusion band; wrap gained an optional per-line width callback (the no-float path is the exact old code) and placeLines shortens and offsets overlapping lines; clear drops the flow cursor below matching floats. Simplifications are commented at the source: same-side floats stack downward, blocks grow to contain their floats so pagination never splits across one, band widths use the authored line-height estimate, and a widthless floated block takes half the content width as shrink-to-fit. Sweep moves 4148 -> 4149 pages (+1): a real book reflowing under inline-image layout, recorded in the progress log. Existing tests that relied on every img being a block now say display:block explicitly.
1 parent 0a4cb33 commit 17aafc9

14 files changed

Lines changed: 483 additions & 21 deletions

File tree

FABLE5_AUDIT_PROGRESS.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,58 @@ kinsoku, T-73 reader settings, T-66 inline images + floats, T-47 WOFF2).
461461

462462
---
463463

464+
## T-66. Inline images and floats
465+
466+
- **Status:** DONE
467+
- **Commit:** (this commit)
468+
- **What landed:**
469+
- **Inline images:** `processInline` no longer drops `<img>` (the stale
470+
"Phase 5" skip is gone), and `buildBlock` routes an img by its
471+
computed display: inline (the CSS default; the resolver's initial
472+
display is already INLINE) flows on the line, `display:block` or a
473+
float keeps the old block `ImageBox` path. An inline image is one
474+
unbreakable single-cell token sized from CSS width/height, the HTML
475+
attributes (x0.75 px->pt), or the intrinsic pixel size at 0.75pt/px,
476+
capped to the content width; it advances the pen, sits bottom on the
477+
baseline, and grows the line's ascent to its height. Carried through
478+
`PositionedLine.images` as `PlacedImage` (parallel to `PlacedRun`,
479+
with the natural draw width kept separate from the possibly
480+
justified pen advance) and painted next to the run loop in
481+
`EpubPage.renderTo` (raster + SVG). Extraction/search skip images
482+
automatically (they live outside the glyph runs).
483+
- **Floats:** `float`/`clear` parsed into `ComputedStyle`. A floated
484+
child lays out against the left/right content edge at the current
485+
flow y and registers a `FloatBand` exclusion; the flow cursor does
486+
not advance. `wrap` gained an optional per-line-width callback
487+
(`availAt`, null = the exact old single-width path) and
488+
`placeLines` shortens and offsets each overlapping line via
489+
`floatInsets`; `clear` drops the cursor below matching floats.
490+
Commented simplifications per the audit: same-side floats stack
491+
downward; floats do not escape their containing block (it grows to
492+
contain them, protecting pagination); a float paginates as one
493+
unbreakable unit; float-band widths use the authored line-height
494+
estimate (ruby/image-grown lines may drift by their growth); a
495+
floated block without explicit width takes half the content width
496+
as its shrink-to-fit approximation.
497+
- **Verification:** `InlineImageFloatTest` (6 tests): fixture A (no
498+
content loss, image draws between its neighbouring words, bottom
499+
exactly on the baseline by draw-ctm equality, ascent grows to the
500+
40pt image), fixture B (lines beside a float:left start right of it;
501+
lines below return to full width; float:right mirrors), and clear
502+
(content starts below the float's 100pt band). Four existing tests
503+
that relied on every img being a block were pointed at
504+
`display:block` explicitly; the raster blit test sizes its tiny PNG
505+
up (a bare img now draws at intrinsic size, the CSS behaviour, not
506+
full-width).
507+
- **Sweep:** 4148 -> 4149 pages (+1), 0 failures, worstMAE 0.2666
508+
(unchanged at 4 s.f.). The +1 is a real book reflowing under
509+
inline-image layout; the harness prints only aggregates, so the
510+
gaining book was not isolated (the per-book p0 MAE list is stable,
511+
consistent with mid-chapter images moving line flow, not covers).
512+
PDF differential untouched (29 pages, 0.0115).
513+
514+
---
515+
464516
## Discovered during execution
465517

466518
(nothing yet)

kitepdf-epub/src/commonMain/kotlin/io/github/yuroyami/kitepdf/epub/BoxBuilder.kt

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.yuroyami.kitepdf.epub
22

33
import io.github.yuroyami.kitepdf.epub.css.ComputedStyle
4+
import io.github.yuroyami.kitepdf.epub.css.CssFloat
45
import io.github.yuroyami.kitepdf.epub.css.Display
56
import io.github.yuroyami.kitepdf.epub.css.Edge
67
import io.github.yuroyami.kitepdf.epub.css.ListType
@@ -71,10 +72,19 @@ internal class BoxBuilder(
7172
if (child.tag == "img" || child.tag == "image") {
7273
val src = child.attrs["src"] ?: child.attrs["href"] ?: child.attrs["xlink:href"]
7374
if (src != null && src.isNotBlank()) {
74-
flush()
75+
val cs = resolver.compute(child, childAncestors, style)
7576
val aw = child.attrs["width"]?.trim()?.removeSuffix("px")?.toDoubleOrNull()
7677
val ah = child.attrs["height"]?.trim()?.removeSuffix("px")?.toDoubleOrNull()
77-
children.add(ImageBox(resolver.compute(child, childAncestors, style), resolveHref(src), attrWidth = aw, attrHeight = ah))
78+
// img is inline by default (CSS): it flows on the line
79+
// unless the author blocks or floats it.
80+
if ((cs.display == Display.INLINE || cs.display == Display.INLINE_BLOCK) &&
81+
cs.cssFloat == CssFloat.NONE
82+
) {
83+
inl.addImage(resolveHref(src), style, cs.widthPt ?: aw?.times(0.75), cs.heightPt ?: ah?.times(0.75))
84+
} else {
85+
flush()
86+
children.add(ImageBox(cs, resolveHref(src), attrWidth = aw, attrHeight = ah))
87+
}
7888
}
7989
continue
8090
}
@@ -282,7 +292,17 @@ internal class BoxBuilder(
282292
is HtmlNode.Text -> inl.appendText(child.text, style)
283293
is HtmlNode.Element -> {
284294
if (child.tag == "br") { inl.addBreak(); continue }
285-
if (child.tag == "img" || child.tag == "image") continue // inline images: Phase 5
295+
if (child.tag == "img" || child.tag == "image") {
296+
// Inline image: flows on the line, bottom on the baseline.
297+
val src = child.attrs["src"] ?: child.attrs["href"] ?: child.attrs["xlink:href"]
298+
if (src != null && src.isNotBlank()) {
299+
val cs = resolver.compute(child, childAncestors, style)
300+
val aw = child.attrs["width"]?.trim()?.removeSuffix("px")?.toDoubleOrNull()?.times(0.75)
301+
val ah = child.attrs["height"]?.trim()?.removeSuffix("px")?.toDoubleOrNull()?.times(0.75)
302+
inl.addImage(resolveHref(src), style, cs.widthPt ?: aw, cs.heightPt ?: ah)
303+
}
304+
continue
305+
}
286306
val cs = resolver.compute(child, childAncestors, style)
287307
if (cs.display != Display.NONE) processInline(child, cs, childAncestors, inl, anchorSink)
288308
}
@@ -389,6 +409,17 @@ internal class BoxBuilder(
389409
pendingSpace = false; lastWasBreak = true
390410
}
391411

412+
/** An inline `<img>`: one U+FFFC run carrying the source + size hints. */
413+
fun addImage(src: String, style: ComputedStyle, cssW: Double?, cssH: Double?) {
414+
if (pendingSpace && blockHasContent && !lastWasBreak) {
415+
runs.add(makeRun(" ", style))
416+
}
417+
pendingSpace = false; lastWasBreak = false; blockHasContent = true
418+
runs.add(
419+
makeRun("", style).copy(imageSrc = src, imageCssW = cssW, imageCssH = cssH),
420+
)
421+
}
422+
392423
fun appendText(raw: String, style: ComputedStyle) {
393424
if (raw.isEmpty()) return
394425
if (style.whiteSpace == WhiteSpaceMode.PRE || style.whiteSpace == WhiteSpaceMode.PRE_WRAP || style.whiteSpace == WhiteSpaceMode.PRE_LINE) {

0 commit comments

Comments
 (0)