diff --git a/.changeset/annotations-start-from-controller.md b/.changeset/annotations-start-from-controller.md new file mode 100644 index 000000000..a8c5de935 --- /dev/null +++ b/.changeset/annotations-start-from-controller.md @@ -0,0 +1,5 @@ +--- +'@ritojs/react': patch +--- + +`useAnnotations` starts from the records the controller already holds: those its annotation storage loaded before the hook listened were told by no event, so a book opened again listed no annotations until the next change (a scrolled view, which draws them from that list, showed none). diff --git a/.changeset/atom-line-annotation-space.md b/.changeset/atom-line-annotation-space.md new file mode 100644 index 000000000..21e023621 --- /dev/null +++ b/.changeset/atom-line-annotation-space.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A ruby line after a line holding only an image (an illustration paragraph) reaches only into the space under the image, as Chromium measures that line's content by the image, instead of half the image line's height. diff --git a/.changeset/atom-vertical-align-middle.md b/.changeset/atom-vertical-align-middle.md new file mode 100644 index 000000000..bc7727a24 --- /dev/null +++ b/.changeset/atom-vertical-align-middle.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An image or inline-block styled `vertical-align: middle` centres on its line as Chromium places it: its box moves by half its ascent less its descent, less half its parent's x-height, so its middle sits half the parent's x-height above the parent's baseline, down a vertical line too. calibre's centred plate pages, a page-wide spacer and a full-height block both aligned middle, now stand where Chromium draws them. diff --git a/.changeset/block-sizes-on-layout-grid.md b/.changeset/block-sizes-on-layout-grid.md new file mode 100644 index 000000000..e1bf393c2 --- /dev/null +++ b/.changeset/block-sizes-on-layout-grid.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A box's fixed, minimum and maximum block size sits on the LayoutUnit grid, truncated as Chromium stores a used size: paragraphs held to `min-width: 1.6em` down a vertical page stack 25.59375px apart instead of 25.6, so their excess no longer piles up over a page and moves where the next page's lines start. diff --git a/.changeset/box-edges-as-tags.md b/.changeset/box-edges-as-tags.md new file mode 100644 index 000000000..ae48f65a0 --- /dev/null +++ b/.changeset/box-edges-as-tags.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An inline element's start and end margin, border and padding take their width as Chromium's open and close tags do: a line that breaks where the element opens sends that start to the next line with the element's content, instead of leaving it on the line above or pushing the character before it down; nested elements each add their own, an empty element takes its width, vertical text reads the top and bottom sides, and percentage padding resolves against the containing block. diff --git a/.changeset/break-before-past-page-end.md b/.changeset/break-before-past-page-end.md new file mode 100644 index 000000000..32ee37e76 --- /dev/null +++ b/.changeset/break-before-past-page-end.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A block whose start lands past the end of the page, after a line or plate taller than the page, opens the next page however small it is, as Chromium breaks before it: a page-break `
` after an over-wide plate no longer hangs off the bottom of the plate's page. diff --git a/.changeset/broken-box-keeps-the-page.md b/.changeset/broken-box-keeps-the-page.md new file mode 100644 index 000000000..8502e27d7 --- /dev/null +++ b/.changeset/broken-box-keeps-the-page.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A box that breaks across pages paints as Chromium slices it: the fragment that breaks before or inside a child (or between a paragraph's lines) keeps the rest of the page, and no fragment strokes a border on an edge the break cut. A bordered box whose illustration moves to the next page draws its side borders down to the page bottom above it, with no bottom border there and no top border where it resumes. diff --git a/.changeset/broken-image-without-alt.md b/.changeset/broken-image-without-alt.md new file mode 100644 index 000000000..b616d7024 --- /dev/null +++ b/.changeset/broken-image-without-alt.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A missing image with no `alt` attribute lays out as Chromium's 16px broken-image icon, as one with alt text does; only an empty `alt` collapses. A missing image in a short line no longer leaves every line after it a pixel high. diff --git a/.changeset/canvas-image-formats.md b/.changeset/canvas-image-formats.md new file mode 100644 index 000000000..b8a4071c9 --- /dev/null +++ b/.changeset/canvas-image-formats.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +The browser reader decodes GIF, WebP and BMP images as well as PNG and JPEG — every format Core sizes — instead of painting their placeholder. diff --git a/.changeset/chromium-line-breaking.md b/.changeset/chromium-line-breaking.md new file mode 100644 index 000000000..40c1f20b9 --- /dev/null +++ b/.changeset/chromium-line-breaking.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +Lines break where Chromium breaks them in every language. Each text item reads the ICU line rules its content language (`lang`) and `line-break` value select, as Chromium's ICU does: Chinese text, and Japanese and Korean text under `line-break: normal` or `loose`, reads the curly double quotes as brackets and may start a line with 〜 or ゠ (Japanese breaks only by default otherwise); `loose` also breaks before iteration marks and centered punctuation and between inseparables; a quotation mark breaks only between East Asian characters; and a number keeps its prefix and postfix together only as a number does. `word-break: break-all` and `keep-all` add and withhold breaks per text item as Blink does, and `line-break: anywhere` applies only to its own text. A line broken before spaces ends after them (a space followed by an ideographic space ends the line after the space), and a no-break space never hangs past the line's end. `-webkit-line-break` and `-epub-word-break` (and `-epub-text-transform` and `-epub-caption-side`) reach the engine as the properties Chromium aliases them to. diff --git a/.changeset/clearing-block-keeps-its-margin.md b/.changeset/clearing-block-keeps-its-margin.md new file mode 100644 index 000000000..554e9417e --- /dev/null +++ b/.changeset/clearing-block-keeps-its-margin.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A block with content that clears the floats before it keeps its top margin inside its container, as Chromium settles the container's position above a block cleared past adjoining floats: floats opening a box no longer move down by the margin of the paragraph that clears them. diff --git a/.changeset/closers-before-a-break.md b/.changeset/closers-before-a-break.md new file mode 100644 index 000000000..7eb765ee4 --- /dev/null +++ b/.changeset/closers-before-a-break.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A chapter written as one long `
`-broken paragraph no longer has its text shaped again for every wrapped line that ends in a closing bracket or quote before a `
`; what is laid out is unchanged. diff --git a/.changeset/closers-trim-across-elements.md b/.changeset/closers-trim-across-elements.md new file mode 100644 index 000000000..eda73b58b --- /dev/null +++ b/.changeset/closers-trim-across-elements.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A closing bracket or quote that overflows a line by half its width is trimmed to keep it on the line when the line crosses an element boundary and when the closer ends its element, as Chromium does since version 153: such a line no longer sends a whole note or the run after an annotated name down to the next line, and a trimmed closer keeps the end padding of the box it closes. diff --git a/.changeset/column-images-snap.md b/.changeset/column-images-snap.md new file mode 100644 index 000000000..d2c4cd680 --- /dev/null +++ b/.changeset/column-images-snap.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An image in a vertical line paints placed by its `object-fit` and snapped to whole CSS pixels, as an image in a horizontal line does: a plate centred down a column no longer lands a pixel off. diff --git a/.changeset/contain-snaps-after-placement.md b/.changeset/contain-snaps-after-placement.md new file mode 100644 index 000000000..21622d02b --- /dev/null +++ b/.changeset/contain-snaps-after-placement.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An image letterboxed by `object-fit: contain` is placed in its unsnapped box and the placed rect snaps to whole pixels, as Chromium snaps it: a thin rule image scaled into a fractional box now rasters on the one row the browser draws it on instead of smearing across two. A raster placed under a pixel tall or wide keeps one pixel where its start rounds, as the browser keeps it, instead of reaching the hosts as an empty image they reject; only one thinner than four LayoutUnits paints nothing. diff --git a/.changeset/han-kerning-classes.md b/.changeset/han-kerning-classes.md new file mode 100644 index 000000000..c3d346e5f --- /dev/null +++ b/.changeset/han-kerning-classes.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +Adjacent CJK punctuation trims by Chromium's punctuation classes for every bracket and quote: the CJK double prime quotes (`〝〞〟`) and any other fullwidth bracket trim like `「」`, a fullwidth opener after a narrow one (`(「`) and a fullwidth closer before a narrow one (`」)`) trim, the middle dots `·` and `‧` make their neighbours trim as `・` does, and each of these closers can trim at a line end. diff --git a/.changeset/inline-block-margin-box.md b/.changeset/inline-block-margin-box.md new file mode 100644 index 000000000..9243e01d5 --- /dev/null +++ b/.changeset/inline-block-margin-box.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An inline block stands on its line by its margin box, as Chromium lays it out: its margins, borders and padding surround its content, take their room along the line and push its last line's baseline down, so the line it rides grows by them (a translation wrapped in `display: inline-block; margin: 8px 0` sits 8px clear of the text above and below it). diff --git a/.changeset/inline-blocks-as-atomic-inlines.md b/.changeset/inline-blocks-as-atomic-inlines.md new file mode 100644 index 000000000..c90266670 --- /dev/null +++ b/.changeset/inline-blocks-as-atomic-inlines.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An element styled `display: inline-block` lays out as Chromium's atomic inline whatever its tag: it takes its own `width` and `height` (a percentage across a vertical line resolving against the page when the line's own size is open, as Chromium's orthogonal fallback does), an empty one still stands on its line at its size, and one holding blocks lays them out inside it. calibre's centred-plate pages now land on the page Chromium shows them on, with the plate centred across it. diff --git a/.changeset/inline-box-after-forced-break.md b/.changeset/inline-box-after-forced-break.md new file mode 100644 index 000000000..45251021f --- /dev/null +++ b/.changeset/inline-box-after-forced-break.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An inline-block right after a forced break starts the next line when a box measures its content's widest line, as Chromium measures it: a shrink-to-fit table column holding `text
` takes the wider of the two lines instead of their sum, and an inline box opening there brings its padding along. diff --git a/.changeset/inline-table-monolithic.md b/.changeset/inline-table-monolithic.md new file mode 100644 index 000000000..95366f9f3 --- /dev/null +++ b/.changeset/inline-table-monolithic.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An inline table (`display: inline-table`) moves to the next page whole when it does not fit what is left of the page, as Chromium fragments an atomic inline, instead of breaking between its rows like a block table. diff --git a/.changeset/justified-item-exact-advance.md b/.changeset/justified-item-exact-advance.md new file mode 100644 index 000000000..30ab513a3 --- /dev/null +++ b/.changeset/justified-item-exact-advance.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +On a justified line, each inline item (a ruby pair, a styled span, the text between them) ends at the 1/64px ceiling of its exact advance, its glyphs' fixed-point advances plus its justification shares, as Chromium snaps an item's width; a share that lifts an item only slightly past a grid point no longer leaves it on the point below. diff --git a/.changeset/justify-no-break-space.md b/.changeset/justify-no-break-space.md new file mode 100644 index 000000000..0ca5651ec --- /dev/null +++ b/.changeset/justify-no-break-space.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A justified line spreads the gap after a no-break space with the other word gaps, as Chromium treats the no-break space as a space when it justifies (a numbered item such as "2. Particular care…" with a no-break space after its number no longer keeps that gap narrow), and a no-break space ending a justified line counts as content instead of hanging. diff --git a/.changeset/ligature-joins.md b/.changeset/ligature-joins.md new file mode 100644 index 000000000..cf45e48b4 --- /dev/null +++ b/.changeset/ligature-joins.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A ligature paints as one cluster from its start, and the cluster after it joins when the whole ligature steps by its glyph's advance, so the GPU pen and the canvas pen both draw the ligature the font forms: an embedded face's contextual pair of em dashes no longer paints as two isolated dashes a pixel above the browser's joined bar. diff --git a/.changeset/line-breaker-defects.md b/.changeset/line-breaker-defects.md new file mode 100644 index 000000000..c83e9e16d --- /dev/null +++ b/.changeset/line-breaker-defects.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A `
` breaks the line under `line-break: anywhere` and after a preserved space, a paragraph shaped as one run longer than 64 KiB lays out instead of failing, a ligature is measured by its own glyphs in any run, a line rewound to the start of an item keeps an inline image that comes before it, an ideographic space (or another space separator) that overflows a line hangs past its end where the line may break after it, as Chromium hangs it, and a short ruby (a base of at most four glyphs under an annotation of at most eight) moves to the next line whole instead of breaking inside its base, as Chromium keeps it. diff --git a/.changeset/line-end-reshape.md b/.changeset/line-end-reshape.md new file mode 100644 index 000000000..6d6fdd9a8 --- /dev/null +++ b/.changeset/line-end-reshape.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A line's last glyph is measured as Chromium reshapes a line's end: where the shaper marks the break after it unsafe (a kern pair with the space that hangs after it, as Tinos kerns "A" and "’" against the space, or with the next line's first character), the glyph takes the advance it shapes to alone. A line that fits only with that kerning breaks before the glyph, as Chromium breaks it, and a justified line spreads the slack its unkerned end leaves, in Latin text as in CJK. diff --git a/.changeset/lines-decided-as-broken.md b/.changeset/lines-decided-as-broken.md new file mode 100644 index 000000000..f41fcb23b --- /dev/null +++ b/.changeset/lines-decided-as-broken.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A paragraph's line edges (a closer trimmed to fit, a punctuation pair a break separates, a ruby base split across lines) are decided one line at a time as the line is broken, so a paragraph is built at most twice however many lines decide; a pair trim a later line moved away from its break is applied again, as Chromium applies it. diff --git a/.changeset/margin-sets-through-wrappers.md b/.changeset/margin-sets-through-wrappers.md new file mode 100644 index 000000000..44f36ba23 --- /dev/null +++ b/.changeset/margin-sets-through-wrappers.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +Margins collapsing through several plain wrappers join as one set, the largest positive plus the most negative, as CSS collapses adjoining margins: a negative margin deep in a chain of wrappers no longer cancels against only the nearest one, which left a title page's heading lower than in the browser. diff --git a/.changeset/one-reader-paged-and-scrolled.md b/.changeset/one-reader-paged-and-scrolled.md new file mode 100644 index 000000000..6cc3b293c --- /dev/null +++ b/.changeset/one-reader-paged-and-scrolled.md @@ -0,0 +1,6 @@ +--- +'@ritojs/kit': minor +'@ritojs/react': minor +--- + +A book read in pages and scrolled is one reader: switching between them lays the open book out again instead of opening it a second time. The page controller's `suspend()` hands its reader to another view (its canvas off the page, its page buffers let go, no painting, turns, keys or layouts answered, while its annotations, search and stored position stay and its layout calls still lay the reader out), `resume()` takes it back as laid out then, and `suspended` says which. `useRitoScrollView(controller, { active, paged, scroll })` in `@ritojs/react` runs the switch: the reader laid out scrolled and shown from where the pages were, then paged again from where the view was read. A scroll view has its windows as soon as it is made, so it can be moved before it is mounted, and through every new layout it stays on the window the reader reads, which a chapter laid out alone and the whole book number differently (switching to scrolled used to land at the book's start once the whole book was laid out). diff --git a/.changeset/ruby-line-edge-alignment.md b/.changeset/ruby-line-edge-alignment.md new file mode 100644 index 000000000..cf64aebb9 --- /dev/null +++ b/.changeset/ruby-line-edge-alignment.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +In a justified paragraph, a ruby whose annotation is wider than its base sets its base against the line's start when it opens a line, and against the line's end when it ends a justified line, as Chromium aligns ruby at line edges; elsewhere the base stays centered under its annotation. diff --git a/.changeset/ruby-overhang-at-line-edges.md b/.changeset/ruby-overhang-at-line-edges.md new file mode 100644 index 000000000..dbd43b795 --- /dev/null +++ b/.changeset/ruby-overhang-at-line-edges.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A ruby whose annotation is wider than its base overhangs the text before it only when that text is on its own line, as Chromium lays it out: a ruby opening a line starts its annotation at the line's start. A justified, centered or right-aligned line that ends where the next element's first character did not fit aligns as Chromium aligns it, counting each ruby on it at its full width, so such a line ending at a ruby is not stretched. diff --git a/.changeset/ruby-reaches-above-the-line.md b/.changeset/ruby-reaches-above-the-line.md new file mode 100644 index 000000000..06e02c97b --- /dev/null +++ b/.changeset/ruby-reaches-above-the-line.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A paragraph's first-line ruby reaches into the space above the line before the line grows, as Chromium lays it out: the collapsed margin above the paragraph and the space under the previous paragraph's last line, not across a top border or out of a paragraph that is its own formatting context; a ruby line that opens a page takes its whole height there. diff --git a/.changeset/scroll-highlight-hits.md b/.changeset/scroll-highlight-hits.md new file mode 100644 index 000000000..cad56c01e --- /dev/null +++ b/.changeset/scroll-highlight-hits.md @@ -0,0 +1,5 @@ +--- +'@ritojs/kit': minor +--- + +A scroll view says which of the app's highlights the pointer is on, the one drawn on top: its `tap` event carries `highlight` (the highlight's `id` and its `bounds` in screen coordinates), and the new `highlightHover` event tells the one under a resting mouse each time it changes or the strip moves it, null once the mouse leaves or presses, so an app opens an annotation's editor on a tap and shows its tooltip on hover in a scrolled view as a page view's `annotationClick` and `annotationHover` let it. diff --git a/.changeset/scroll-page-length.md b/.changeset/scroll-page-length.md new file mode 100644 index 000000000..b6cbf0de1 --- /dev/null +++ b/.changeset/scroll-page-length.md @@ -0,0 +1,5 @@ +--- +'@ritojs/kit': minor +--- + +A scroll view's `pageLength` says how far a page moves its strip, as PageDown and PageUp move it (the view's length along the axis less two lines), so an app's own page commands — a menu's Previous and Next Page — move it as the keys do. diff --git a/.changeset/scroll-selection-handles-hook.md b/.changeset/scroll-selection-handles-hook.md new file mode 100644 index 000000000..d5933d9f8 --- /dev/null +++ b/.changeset/scroll-selection-handles-hook.md @@ -0,0 +1,5 @@ +--- +'@ritojs/react': minor +--- + +`useScrollSelectionHandles(controller)` gives a scrolled view's selection ends where the view shows them, following the strip as it moves, with `focusEdge` naming the focus: what a toolbar over a scrolled selection stands on, as `useSelection`'s `focusRect` is for pages. `ScrollSelectionHandles` is built on it. diff --git a/.changeset/scroll-view-selects-in-the-app.md b/.changeset/scroll-view-selects-in-the-app.md new file mode 100644 index 000000000..97f46af01 --- /dev/null +++ b/.changeset/scroll-view-selects-in-the-app.md @@ -0,0 +1,5 @@ +--- +'@ritojs/kit': patch +--- + +Text selection works in a scroll view a React host mounts through a ref: mounting the view again where it already is no longer lays the strip out again, which ended every drag and double press and, with each rest re-rendering the host, laid the strip out on every frame. While a selection is being made the whole-book layout waits, as it does for the page controller, so a layout landing mid-drag does not end the gesture. diff --git a/.changeset/scrolled-reading.md b/.changeset/scrolled-reading.md new file mode 100644 index 000000000..e0561b74a --- /dev/null +++ b/.changeset/scrolled-reading.md @@ -0,0 +1,7 @@ +--- +'@ritojs/core': minor +'@ritojs/kit': minor +'@ritojs/react': minor +--- + +Scrolled reading: a reader created with `spread: 'scrolled'` lays each chapter out as one continuous strip read through windows the size of the viewport, one window a spread, down the screen, or leftwards for a book whose spine's `page-progression-direction` is `rtl` (its vertical-rl chapters run leftwards as strips; a chapter whose text runs across the book's axis is paginated, each page one window). `getScrollLayout()` gives the windows end to end (axis, window length, each chapter's first spread, window count and extent), a located page's `blockOffset` says where the locator's line starts in its window, and a target the size of a window paints that window alone. The kit's `createScrollController(reader, canvas, { physics, input })` shows the windows end to end on one canvas at whole device pixels, keeps each window's picture while it is near, tells the reader which window is under the reading line, reports where the reader reads at rest and what a tap is on, and moves by `defaultScrollPhysics` or any `ScrollPhysics` an app gives; an app drives it itself with `grab`, `follow`, `release`, `scrollTo`, `scrollBy`, `scrollToSpread` and `scrollToLocator`. The view selects text with a mouse drag or a touch held still across windows (word and paragraph presses, scrolling on near the ends) and moves the selection's focus with Shift chords, following it off screen, draws the selection over each window and finds it again in a new layout (`selection` event, `selectionRects`, `clearSelection`), and `setHighlights` draws an app's search matches or annotations as page rects over the same windows (`viewRects` maps page rects onto the view, `canvas` is what it draws on); with `a11y` it mirrors the text on screen for screen readers once the strip rests. Its `scroll` event says how far through the book the strip is (`progress`, with the view's own length as `viewport`). `useRitoScrollReader` in `@ritojs/react` owns a scrolled reader and its view for a component (`containerRef`, `load`, `unload`, and `progress`, `position` and `selection` as state), and `ScrollSelectionHandles` draws its selection handles for touch input; the kit view gives the handles (`selectionHandles`, `beginSelectionHandleDrag`) in the page controller's shapes. diff --git a/.changeset/software-webgl-canvas-pen.md b/.changeset/software-webgl-canvas-pen.md new file mode 100644 index 000000000..555a2f39b --- /dev/null +++ b/.changeset/software-webgl-canvas-pen.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +Where WebGL is a software rasterizer (SwiftShader, llvmpipe — a machine with no GPU), the canvas pen paints every page and the GPU only copies it in, as it already did where the browser refused WebGL for a performance caveat: Chromium creates a context on SwiftShader all the same, and the GPU pen there blended text a level or two off the canvas. diff --git a/.changeset/text-emphasis.md b/.changeset/text-emphasis.md new file mode 100644 index 000000000..de2e2ebbe --- /dev/null +++ b/.changeset/text-emphasis.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': minor +--- + +Emphasis marks paint as Chromium paints them. `text-emphasis-style`, `text-emphasis-color`, `text-emphasis-position` and the `text-emphasis` shorthand (with their `-webkit-` spellings, and the `-epub-` spellings of all but the position) reach the engine; a character that takes a mark gets one, set in its face at half its size, outside its em box on the side the position names (a vertical line's right unless it says left), an upright mark centred on it down the column. Marks over a line push it down by what the room under the previous line's text does not absorb, marks under one grow its box, and the next line rises into that growth by the room above its own text. diff --git a/.changeset/unlinked-stylesheets.md b/.changeset/unlinked-stylesheets.md new file mode 100644 index 000000000..86d886d84 --- /dev/null +++ b/.changeset/unlinked-stylesheets.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +A chapter lays out under the stylesheets it links and embeds and no others, as a browser applies them: a chapter without a `` no longer takes every stylesheet the package lists (a book's unlinked note and plate pages keep their default paragraphs and image layout where its stylesheet indents and frames the other chapters). diff --git a/.changeset/vertical-records.md b/.changeset/vertical-records.md new file mode 100644 index 000000000..bcd2b814d --- /dev/null +++ b/.changeset/vertical-records.md @@ -0,0 +1,7 @@ +--- +'@ritojs/core': minor +'@ritojs/kit': minor +'@ritojs/react': minor +--- + +A vertical-rl page's targets, outline, range rectangles and carets are where its columns are drawn, in the same page-content space as a horizontal page's, paged or scrolled; a scrolled window's records move with its pixels by the device rows it is drawn from. A caret's `geometry` gains `width`: it is the segment across its line, `width` 0 on a horizontal line and `height` 0 down a vertical one, and the kit projects it with that shape. `ReaderTextCaret.writingMode` (`ReaderWritingMode`) is the mode of the caret's line, and the kit's keyboard selection reads an arrow key on a `vertical-rl` focus as the key a quarter turn from it, as the browser does (down moves along the line, left to the next line). The React selection handles hang their knobs off the right and left ends of a caret across a vertical line, as they hang them over and under one across a horizontal line. diff --git a/.changeset/vertical-ruby-base-origin.md b/.changeset/vertical-ruby-base-origin.md new file mode 100644 index 000000000..528c98603 --- /dev/null +++ b/.changeset/vertical-ruby-base-origin.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +In a vertical line, the text of a ruby element moves by the rounding of the element's start down the line to a whole CSS pixel, every base of one `` with the first, as Chromium paints a box's text; plain text keeps its fractional place. diff --git a/.changeset/vertical-text.md b/.changeset/vertical-text.md new file mode 100644 index 000000000..e7b9de82f --- /dev/null +++ b/.changeset/vertical-text.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': minor +--- + +Vertical text is laid out and painted as Chromium does it. A vertical-rl chapter's boxes take their margins, padding, borders and sizes on the sides its flow puts them, and their backgrounds and borders paint there; each line hangs from its central baseline; each upright glyph stands in its face's vertical forms at the font's vertical origin and advances down the column by its vertical advance (by the face's whole-pixel height in a face without vertical metrics), and characters that lie sideways in a mixed line (Latin, digits) are set turned a quarter; a ruby annotation reaches into the space before its line and stands on its base where Chromium places it; an image sizes to the column's length; `text-orientation` (with its `-webkit-` and `-epub-` spellings) reaches the engine; and `text-combine-upright: all` sets its text as one horizontal string within an em of the column, in the face's half-, third- or quarter-width forms or scaled when the string is wider than 1.1em. An inline box whose writing mode differs from its parent's lays out as an inline-block across the parent's lines, and a block's minimum and maximum block size (`min-height`/`max-height`, or `min-width`/`max-width` in a vertical flow) bound it. An artifact's font references carry `features` (reader protocol 9): a reference with features is the same bytes as a face, registered under a family of its own with those OpenType features on (`vert`, the vertical forms; `hwid`, `twid` and `qwid`, the narrower forms a combined string takes), which the browser reader registers as its `font-feature-settings` descriptor. diff --git a/.changeset/vertical-underline.md b/.changeset/vertical-underline.md new file mode 100644 index 000000000..5f61e77ba --- /dev/null +++ b/.changeset/vertical-underline.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +An underlined or overlined run in a vertical line (a link in a vertical table of contents) draws its line down beside the column, where Chromium draws it: Japanese and Korean text swaps the two lines, its underline on the right and its overline on the left, where other text draws its underline, a pixel off the em box. diff --git a/.changeset/word-movement-skips-spaces.md b/.changeset/word-movement-skips-spaces.md new file mode 100644 index 000000000..1b8fd124d --- /dev/null +++ b/.changeset/word-movement-skips-spaces.md @@ -0,0 +1,5 @@ +--- +'@ritojs/core': patch +--- + +Extending a selection by a word (Option+Shift+Arrow on Apple platforms, Ctrl+Shift+Arrow elsewhere) moves to the end of the next word or the start of the previous one past spaces and punctuation, as the browser does; it stopped right after the space. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index db1bff905..18f2996f5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,10 @@ jobs: - name: Install matching wasm-bindgen CLI run: cargo install wasm-bindgen-cli --version 0.2.120 --locked - run: pnpm install --frozen-lockfile + # The demo book split by the repository's own packer, which the + # reader's "Sharded Demo" opens: it is not committed, so it is packed + # here, before the reader's build copies it from public/. + - run: pnpm --filter @ritojs/reader run pack:shards-demo - run: pnpm run build - uses: actions/upload-pages-artifact@v3 with: diff --git a/Cargo.lock b/Cargo.lock index 0a0634cac..85e28ed06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1066,6 +1066,7 @@ dependencies = [ name = "rito-inline" version = "0.0.0" dependencies = [ + "icu_properties", "parley", "rito-fragment", "rito-style-contract", diff --git a/apps/reader/package.json b/apps/reader/package.json index b32ebf7c0..33996e5b2 100644 --- a/apps/reader/package.json +++ b/apps/reader/package.json @@ -35,13 +35,13 @@ "vaul": "^1.1.2" }, "devDependencies": { - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.64.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.0", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", "tailwindcss": "^4.2.2", - "vite": "^6.4.3" + "vite": "^6.4.5" } } diff --git a/apps/reader/playwright.config.ts b/apps/reader/playwright.config.ts index 541063ae4..bb476db5e 100644 --- a/apps/reader/playwright.config.ts +++ b/apps/reader/playwright.config.ts @@ -48,7 +48,13 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'], - channel: process.env['PLAYWRIGHT_BROWSER_CHANNEL'], + // The full Chromium build: it rasterizes page and canvas text on + // the GPU as Chrome does, where the headless shell uses the CPU. + channel: process.env['PLAYWRIGHT_BROWSER_CHANNEL'] ?? 'chromium', + // No smart minimum font size: Chromium lifts em- and %-sized text + // to a minimum its UI language and platform choose, which CSS does + // not define and the engine never applies. + launchOptions: { args: ['--blink-settings=minimumLogicalFontSize=0'] }, }, }, ], diff --git a/apps/reader/scroll.html b/apps/reader/scroll.html new file mode 100644 index 000000000..bff5e517a --- /dev/null +++ b/apps/reader/scroll.html @@ -0,0 +1,50 @@ + + + + + + Rito scrolled reading + + + + + +
+
+
+ + + diff --git a/apps/reader/src/App.tsx b/apps/reader/src/App.tsx index d2a92fdff..df18c3150 100644 --- a/apps/reader/src/App.tsx +++ b/apps/reader/src/App.tsx @@ -9,6 +9,8 @@ import { ReadingProgressArea } from '@/components/reading-progress'; import { readerViewportMargin, useReader } from '@/hooks/use-reader'; import { useTheme } from '@/hooks/use-theme'; import { Reader } from '@/components/reader'; +import { ScrollReaderPane } from '@/components/scroll-reader-pane'; +import { ReaderContextMenu } from '@/components/reader-context-menu'; interface AppProps { readonly pinnedFontPolicy: ReaderPinnedFontPolicy; @@ -69,9 +71,8 @@ export function App({ pinnedFontPolicy }: AppProps) { return (
- - - + > + { + void reader.annotateScrollSelection(kind, color, note); + }} + footnote={reader.footnote} + onCloseFootnote={reader.dismissFootnote} + image={reader.lightboxImage} + onCloseImage={reader.dismissLightbox} + onImageExitComplete={reader.onLightboxExitComplete} + /> + + ) : ( + + + + )} | null; annotations: ReturnType['annotations']; onClose: () => void; } diff --git a/apps/reader/src/components/annotation-toolbar.tsx b/apps/reader/src/components/annotation-toolbar.tsx index 23f0cfc7a..6c09a1f44 100644 --- a/apps/reader/src/components/annotation-toolbar.tsx +++ b/apps/reader/src/components/annotation-toolbar.tsx @@ -17,42 +17,55 @@ interface Props { annotations: ReturnType['annotations']; } -type AnnotationKind = 'highlight' | 'underline' | 'note'; +export type AnnotationKind = 'highlight' | 'underline' | 'note'; + +interface SelectionToolbarProps { + /** The selection's focus, which the toolbar stands over, in its container's CSS pixels. */ + readonly anchor: { readonly x: number; readonly y: number }; + /** Makes the selection an annotation; the toolbar is done with it then. */ + readonly onAnnotate: (kind: AnnotationKind, color: string, note?: string) => void; +} function stop(e: React.SyntheticEvent): void { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); } +/** The paged reader's selection, made an annotation through the controller. */ +export function AnnotationToolbar({ selection, annotations }: Props) { + if (!selection.hasSelection || !selection.focusRect || !selection.text) { + return null; + } + return ( + { + annotations.add({ kind, color, ...(note ? { note } : {}) }).catch((error: unknown) => { + toast.error('Could not add the annotation', { + description: error instanceof Error ? error.message : String(error), + }); + }); + selection.clear(); + }} + /> + ); +} + /** * What the selection becomes: a highlight in one of four colours, an - * underline, or a note (a highlight that carries text). + * underline, or a note (a highlight that carries text), over its focus. */ -export function AnnotationToolbar({ selection, annotations }: Props) { +export function SelectionToolbar({ anchor, onAnnotate }: SelectionToolbarProps) { const [showNote, setShowNote] = useState(false); const [note, setNote] = useState(''); const [pickedColor, setPickedColor] = useState(ANNOTATION_COLORS[0].value as string); - if (!selection.hasSelection || !selection.focusRect || !selection.text) { - return null; - } - - const anchor = selection.focusRect; - - // focusRect lives in viewport-logical coordinates, so convert it to display CSS px. const top = anchor.y - (showNote ? 110 : 60); const left = anchor.x; const submit = (kind: AnnotationKind, color: string, noteText?: string) => { - if (!selection.hasSelection) return; - annotations - .add({ kind, color, ...(noteText ? { note: noteText } : {}) }) - .catch((error: unknown) => { - toast.error('Could not add the annotation', { - description: error instanceof Error ? error.message : String(error), - }); - }); - selection.clear(); + onAnnotate(kind, color, noteText); setShowNote(false); setNote(''); }; diff --git a/apps/reader/src/components/annotation-tooltip.tsx b/apps/reader/src/components/annotation-tooltip.tsx index 9a069bcdd..a1856993e 100644 --- a/apps/reader/src/components/annotation-tooltip.tsx +++ b/apps/reader/src/components/annotation-tooltip.tsx @@ -1,15 +1,22 @@ import { useRef } from 'react'; +import type { AnnotationRecord } from '@ritojs/kit'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import type { useReader } from '@/hooks/use-reader'; interface Props { - hover: ReturnType['annotations']['hover']; + /** The annotation the mouse rests on and the tooltip's place (screen coordinates); null for none. */ + hover: { + readonly annotation: { readonly record: AnnotationRecord }; + readonly x: number; + readonly y: number; + } | null; } export function AnnotationTooltip({ hover }: Props) { const staleRef = useRef(hover); if (hover) staleRef.current = hover; const display = hover ?? staleRef.current; + // An emptied note is stored empty (see AnnotationDialog): there is none to show. + const note = display?.annotation.record.note?.trim(); return ( @@ -19,9 +26,7 @@ export function AnnotationTooltip({ hover }: Props) { style={{ top: display?.y ?? 0, left: display?.x ?? 0, width: 1, height: 1 }} /> - - {display?.annotation.record.note ?? 'Click to edit'} - + {note ? note : 'Click to edit'} ); } diff --git a/apps/reader/src/components/reader-context-menu.tsx b/apps/reader/src/components/reader-context-menu.tsx index 5cd304b02..fad567dcb 100644 --- a/apps/reader/src/components/reader-context-menu.tsx +++ b/apps/reader/src/components/reader-context-menu.tsx @@ -1,5 +1,5 @@ import { toast } from 'sonner'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { Bug, ChevronLeft, @@ -51,30 +51,18 @@ export function ReaderContextMenu({ onFileLoad, children, }: Props) { - const hasSelection = reader.selection.hasSelection && reader.selection.text.length > 0; - const canPrev = reader.isLoaded && reader.currentSpread > 0; - const canNext = reader.isLoaded && reader.currentSpread < reader.spreads.length - 1; + const selection = useMenuSelection(reader); + const turns = menuTurns(reader); + const hasSelection = selection.text.length > 0; const { inputRef, handleChange, openFilePicker } = useFileLoader(onFileLoad); const handleCopy = useCallback(() => { - if (!reader.selection.text) return; - void navigator.clipboard.writeText(reader.selection.text).catch(() => { + if (!selection.text) return; + void navigator.clipboard.writeText(selection.text).catch(() => { // Clipboard access may be denied; swallow silently. }); - reader.selection.clear(); - }, [reader.selection]); - - const handleHighlight = useCallback(() => { - if (!reader.selection.hasSelection) return; - reader.annotations - .add({ kind: 'highlight', color: ANNOTATION_COLORS[0].value }) - .catch((error: unknown) => { - toast.error('Could not add the highlight', { - description: error instanceof Error ? error.message : String(error), - }); - }); - reader.selection.clear(); - }, [reader.annotations, reader.selection]); + selection.clear(); + }, [selection]); const handleCopyDebugLog = useCallback(() => { void copyDebugReport().then((outcome) => { @@ -85,12 +73,12 @@ export function ReaderContextMenu({ }, []); const handleSearchSelection = useCallback(() => { - if (!reader.selection.text) return; - const text = reader.selection.text; - reader.selection.clear(); + if (!selection.text) return; + const text = selection.text; + selection.clear(); reader.search.setQuery(text); reader.setSearchOpen(true); - }, [reader]); + }, [reader, selection]); return ( @@ -106,7 +94,7 @@ export function ReaderContextMenu({ Copy ⌘C - + Highlight @@ -128,25 +116,15 @@ export function ReaderContextMenu({ Contents - { - reader.prevSpread(); - }} - disabled={!canPrev} - > + Previous Page - ← + {turns.prevKey} - { - reader.nextSpread(); - }} - disabled={!canNext} - > + Next Page - → + {turns.nextKey} @@ -177,3 +155,60 @@ export function ReaderContextMenu({ ); } + +/** + * What is selected, the pages' or the scroll view's while the book is read + * scrolled, and what the menu does with it. + */ +function useMenuSelection(reader: ReturnType) { + const scrolled = reader.spreadMode === 'scrolled'; + const text = scrolled ? (reader.scroll.selection?.text ?? '') : reader.selection.text; + const view = reader.scroll.controller; + const { annotations, annotateScrollSelection } = reader; + const pageSelection = reader.selection; + const clear = useCallback(() => { + if (scrolled) view?.clearSelection(); + else pageSelection.clear(); + }, [pageSelection, scrolled, view]); + const highlight = useCallback(() => { + const color = ANNOTATION_COLORS[0].value; + if (scrolled) { + void annotateScrollSelection('highlight', color); + return; + } + if (!pageSelection.hasSelection) return; + annotations.add({ kind: 'highlight', color }).catch((error: unknown) => { + toast.error('Could not add the highlight', { + description: error instanceof Error ? error.message : String(error), + }); + }); + pageSelection.clear(); + }, [annotateScrollSelection, annotations, pageSelection, scrolled]); + return useMemo(() => ({ text, clear, highlight }), [clear, highlight, text]); +} + +/** A page back and on: a turn, or a page of the strip while the book is read scrolled. */ +function menuTurns(reader: ReturnType) { + if (reader.spreadMode === 'scrolled') { + const view = reader.scroll.controller; + const by = (direction: number) => () => { + if (view) view.scrollBy(direction * view.pageLength, { animate: true }); + }; + return { + prev: by(-1), + next: by(1), + canPrev: view !== null && reader.scroll.progress > 0, + canNext: view !== null && reader.scroll.progress < 1, + prevKey: 'PgUp', + nextKey: 'PgDn', + }; + } + return { + prev: reader.prevSpread, + next: reader.nextSpread, + canPrev: reader.isLoaded && reader.currentSpread > 0, + canNext: reader.isLoaded && reader.currentSpread < reader.spreads.length - 1, + prevKey: '←', + nextKey: '→', + }; +} diff --git a/apps/reader/src/components/scroll-reader-pane.tsx b/apps/reader/src/components/scroll-reader-pane.tsx new file mode 100644 index 000000000..d8f4ba944 --- /dev/null +++ b/apps/reader/src/components/scroll-reader-pane.tsx @@ -0,0 +1,139 @@ +import { useRef, useState } from 'react'; +import { Copy, List, Loader2, Search, Settings } from 'lucide-react'; +import { ScrollSelectionHandles, useScrollSelectionHandles } from '@ritojs/react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { FootnoteDrawer } from '@/components/footnote-drawer'; +import { ImageLightbox } from '@/components/image-lightbox'; +import { SelectionToolbar, type AnnotationKind } from '@/components/annotation-toolbar'; +import { AnnotationDialog } from '@/components/annotation-dialog'; +import { AnnotationTooltip } from '@/components/annotation-tooltip'; +import type { useReader } from '@/hooks/use-reader'; +import type { useScrollMode } from '@/hooks/use-scroll-mode'; + +interface ScrollReaderPaneProps { + readonly containerRef: (node: HTMLElement | null) => void; + readonly scroll: ReturnType; + readonly onOpenToc: () => void; + readonly onOpenSearch: () => void; + readonly onOpenSettings: () => void; + /** The book's annotations, edited and removed from their dialog. */ + readonly annotations: ReturnType['annotations']; + /** The annotation a tap opened and the one under the mouse. */ + readonly annotationPointer: ReturnType['scrollAnnotationPointer']; + /** Makes the selection an annotation, as the paged reader's toolbar does. */ + readonly onAnnotate: (kind: AnnotationKind, color: string, note?: string) => void; + readonly footnote: ReturnType['footnote']; + readonly onCloseFootnote: () => void; + readonly image: ReturnType['lightboxImage']; + readonly onCloseImage: () => void; + readonly onImageExitComplete: () => void; +} + +/** The book read scrolled: the kit's scroll view and a small bar over it. */ +export function ScrollReaderPane({ + containerRef, + scroll, + onOpenToc, + onOpenSearch, + onOpenSettings, + annotations, + annotationPointer, + onAnnotate, + footnote, + onCloseFootnote, + image, + onCloseImage, + onImageExitComplete, +}: ScrollReaderPaneProps) { + const selected = scroll.selection?.text ?? ''; + // Handles show for a touch or a pen, as the paged reader's do. + const [touchInput, setTouchInput] = useState(false); + const view = useRef(null); + // The toolbar stands over the selection's focus and moves with the strip. + const handles = useScrollSelectionHandles(scroll.controller); + const focus = handles?.focusEdge === 'start' ? handles.start : (handles?.end ?? null); + return ( +
+
{ + scroll.containerRef(node); + view.current = node; + }} + data-testid="scroll-view" + className="absolute inset-0" + onPointerDownCapture={(event) => { + setTouchInput(event.pointerType === 'touch' || event.pointerType === 'pen'); + }} + /> + + {focus && } + + + {scroll.isLoading && ( +
+ +

Loading...

+
+ )} + {scroll.error && ( +
+
+ {scroll.error} +
+
+ )} +
+ {selected.length > 0 && ( + + )} + + + + {Math.round(scroll.progress * 100)}% +
+ + +
+ ); +} diff --git a/apps/reader/src/components/settings-panel.tsx b/apps/reader/src/components/settings-panel.tsx index 170e7fbc3..aa4609ab1 100644 --- a/apps/reader/src/components/settings-panel.tsx +++ b/apps/reader/src/components/settings-panel.tsx @@ -32,7 +32,7 @@ export interface ReaderSettings { fontFamily: string | null; /** When true, the font family replaces every element's own. */ fontFamilyForce: boolean; - spreadMode: 'single' | 'double'; + spreadMode: 'single' | 'double' | 'scrolled'; theme: 'light' | 'dark'; /** Canvas zoom (`setRenderScale`): sharper glyphs, same pagination. */ renderScale: number; @@ -68,7 +68,7 @@ interface SettingsPanelProps { fonts: ReaderFonts; onRenderScaleChange: (value: number) => void; onPageTurnChange: (value: PageTurn) => void; - onSpreadModeChange: (value: 'single' | 'double') => void; + onSpreadModeChange: (value: 'single' | 'double' | 'scrolled') => void; onThemeChange: (value: 'light' | 'dark') => void; onRestoreDefaults: () => void; } @@ -209,6 +209,7 @@ export function SettingsPanel({ options={[ { value: 'single', label: 'Single Page' }, { value: 'double', label: 'Double Page' }, + { value: 'scrolled', label: 'Scrolled' }, ]} /> diff --git a/apps/reader/src/hooks/use-reader.ts b/apps/reader/src/hooks/use-reader.ts index a23cd2ce5..17baed743 100644 --- a/apps/reader/src/hooks/use-reader.ts +++ b/apps/reader/src/hooks/use-reader.ts @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { preloadReaderRuntime, type ReaderPinnedFontPolicy, type TocEntry } from '@ritojs/core'; import { createPaperTurn, springPresets, type ReaderControllerEvents } from '@ritojs/kit'; import type { PageTurn } from '@/components/settings-panel'; +import type { AnnotationKind } from '@/components/annotation-toolbar'; import { useAnnotations, useControllerEvent, @@ -14,9 +15,17 @@ import { } from '@ritojs/react'; import { bookKey, createBookStorage } from '@/lib/book-storage'; import { noteDebug } from '@/lib/debug-log'; +import { readerViewportMargin } from '@/lib/viewport-margin'; import { fetchShardedDemo } from '@/lib/sharded-demo'; import { useDebugLog } from '@/hooks/use-debug-log'; import { useReaderFonts } from '@/hooks/use-reader-fonts'; +import { useScrollMode } from '@/hooks/use-scroll-mode'; +import { + annotationOfHighlight, + useScrollAnnotationPointer, + useScrollAnnotations, +} from '@/hooks/use-scroll-annotations'; +import { useScrollSearch } from '@/hooks/use-scroll-search'; import { typographyReaderOptions, useApplyTypography, useTypography } from '@/hooks/use-typography'; import demoEpubUrl from '@/assets/demo.epub?url'; @@ -36,7 +45,9 @@ export function useReader( containerHeight: number, pinnedFontPolicy: ReaderPinnedFontPolicy, ) { - const [spreadMode, setSpreadModeState] = useState<'single' | 'double'>('double'); + const [spreadMode, setSpreadModeState] = useState<'single' | 'double' | 'scrolled'>('double'); + // How the book is paged when it is not read scrolled. + const [pagedMode, setPagedMode] = useState<'single' | 'double'>('double'); const typography = useTypography(); const [pageTurn, setPageTurnState] = useState('slide'); // The paper turn simulates one sheet: one of it for this reader. @@ -53,7 +64,7 @@ export function useReader( width: containerWidth > 0 ? Math.round(containerWidth) : 0, height: containerHeight > 0 ? Math.round(containerHeight) : 0, margin: readerViewportMargin(containerWidth), - spread: spreadMode, + spread: pagedMode, pinnedFontPolicy, ...getThemeOptions(theme), ...typographyReaderOptions(typography.typography), @@ -119,7 +130,145 @@ export function useReader( setRenderScaleState(1); }, [controller]); - // Page turns by keyboard only while nothing covers the page. + const scroll = useScrollMode(controller, { + active: spreadMode === 'scrolled', + paged: pagedMode, + }); + useEffect(() => { + // The scroll view too, while the book is read scrolled. + (window as unknown as { __ritoScroll?: unknown }).__ritoScroll = scroll.controller; + }, [scroll.controller]); + + // A tap on a note reference read scrolled opens the note as on a page, + // an image opens in the lightbox (its object URL is the app's to let go), + // and a link into the book goes where it points. + const scrollImageUrl = useRef(null); + const releaseScrollImage = useCallback(() => { + if (scrollImageUrl.current) URL.revokeObjectURL(scrollImageUrl.current); + scrollImageUrl.current = null; + }, []); + // An annotation tapped opens to edit (useScrollAnnotationPointer), before + // any link under it, as on a page. + const annotationRecords = useRef(annotations.annotations); + annotationRecords.current = annotations.annotations; + const scrollAnnotationPointer = useScrollAnnotationPointer( + scroll.controller, + annotations.annotations, + ); + useEffect(() => { + const view = scroll.controller; + const interactions = scroll.reader?.interactions; + if (!view) return; + return view.on('tap', ({ point, target, highlight }) => { + if (highlight && annotationOfHighlight(highlight.id, annotationRecords.current)) return; + if (!target) return; + const follow = () => { + if (target.targetLocator) void view.scrollToLocator(target.targetLocator); + }; + if (target.kind === 'image' && target.imageSrc && scroll.reader) { + const src = target.imageSrc; + const bounds = view.viewRects([{ pageIndex: point.pageIndex, ...target.bounds }])[0]; + const canvas = view.canvas.getBoundingClientRect(); + void Promise.resolve(scroll.reader.getImageBlobUrl(src)).then((blobUrl) => { + if (!blobUrl || !bounds) return; + releaseScrollImage(); + scrollImageUrl.current = blobUrl; + setLightboxImage({ + src, + alt: target.imageAlt ?? '', + blobUrl, + screenBounds: { ...bounds, x: canvas.left + bounds.x, y: canvas.top + bounds.y }, + }); + setLightboxActive(true); + }); + return; + } + const key = target.footnoteKey; + if (target.kind !== 'footnote' || !key || !interactions) { + follow(); + return; + } + void interactions.getFootnote(key).then((content) => { + if (content) setFootnote({ id: key, href: target.href ?? '', content }); + else follow(); + }, follow); + }); + }, [releaseScrollImage, scroll.controller, scroll.reader]); + const scrollSearch = useScrollSearch(scroll); + // The book's annotations and the search result the reader is on, drawn + // over the text read scrolled. + const scrollAnnotations = useScrollAnnotations( + scroll.reader, + annotations.annotations, + scroll.position, + ); + // The selection read scrolled, kept as an annotation among the book's, + // which both modes show, as the paged reader's toolbar keeps it. + const annotateScrollSelection = useCallback( + async (kind: AnnotationKind, color: string, note?: string) => { + const locator = scroll.selection?.range?.sourceLocator; + const interactions = scroll.reader?.interactions; + if (!locator?.sourceRange || !interactions?.createAnnotationTarget) return; + scroll.controller?.clearSelection(); + const target = await interactions.createAnnotationTarget({ + href: locator.href, + sourceRange: locator.sourceRange, + }); + if (!target) { + toast.error('Could not add the annotation'); + return; + } + annotations.insert({ + id: crypto.randomUUID(), + kind, + color, + ...(note ? { note } : {}), + target, + createdAt: Date.now(), + }); + }, + [annotations, scroll.controller, scroll.reader, scroll.selection], + ); + const searchHighlight = scrollSearch.highlight; + useEffect(() => { + scroll.controller?.setHighlights( + searchHighlight ? [...scrollAnnotations, searchHighlight] : scrollAnnotations, + ); + }, [scroll.controller, scrollAnnotations, searchHighlight]); + // Where the book is read scrolled is saved as the pages' position is, so + // the book opens there next time; each rest saves it once. + const scrollProgress = useRef(0); + scrollProgress.current = scroll.progress; + useEffect(() => { + const read = scroll.position; + if (spreadMode !== 'scrolled' || !read) return; + void storage.positionStorage.save( + JSON.stringify({ + sourceLocator: read, + projection: { spreadIndex: 0, pageIndex: 0 }, + progress: scrollProgress.current, + timestamp: Date.now(), + }), + ); + }, [scroll.position, spreadMode]); + // ⌘F / Ctrl+F searches the book read scrolled, as the controller's + // `searchOpen` does for pages. + useEffect(() => { + if (spreadMode !== 'scrolled') return; + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') { + event.preventDefault(); + setSearchOpen(true); + } + }; + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('keydown', onKey); + }; + }, [spreadMode]); + + // Page turns by keyboard only while nothing covers the page (and the + // pages take no keys while a scroll view has the book). const overlayOpen = lightboxActive || pendingLink !== null || footnote !== null || searchOpen; useEffect(() => { controller?.keyboard.setEnabled(!overlayOpen); @@ -171,11 +320,16 @@ export function useReader( } }, [open]); + // Scrolled reading hands the pages' reader to a scroll view from where + // the pages were (useScrollMode); paging again lays it out as chosen, + // from where the view was read, and gives it back to the pages. const setSpreadMode = useCallback( - (mode: 'single' | 'double') => { + (mode: 'single' | 'double' | 'scrolled') => { if (mode === spreadMode) return; setSpreadModeState(mode); - controller?.setSpreadMode(mode); + if (mode === 'scrolled') return; + setPagedMode(mode); + if (spreadMode !== 'scrolled') controller?.setSpreadMode(mode); }, [controller, spreadMode], ); @@ -199,25 +353,37 @@ export function useReader( const navigateToTocEntry = useCallback( (entry: TocEntry) => { + if (spreadMode === 'scrolled') { + const [href = entry.href, anchorId] = entry.href.split('#'); + void scroll.controller?.scrollToLocator(anchorId ? { href, anchorId } : { href }); + return; + } controller?.navigateToTocEntry(entry); }, - [controller], + [controller, scroll.controller, spreadMode], ); const spread = rito.spreads[rito.currentSpread]; const activeTocEntry = - controller && spread ? controller.reader.findActiveTocEntry(spread.leftPageIndex) : undefined; + spreadMode === 'scrolled' + ? scroll.activeTocEntry + : controller && spread + ? controller.reader.findActiveTocEntry(spread.leftPageIndex) + : undefined; return { ...rito, selection, - search, + search: spreadMode === 'scrolled' ? scrollSearch : search, annotations, position, fonts, typography, spreadMode, setSpreadMode, + scroll, + annotateScrollSelection, + scrollAnnotationPointer, renderScale, setRenderScale, isTransitioning: transition.isTransitioning, @@ -243,15 +409,14 @@ export function useReader( }, []), onLightboxExitComplete: useCallback(() => { setLightboxActive(false); - }, []), + releaseScrollImage(); + }, [releaseScrollImage]), searchOpen, setSearchOpen, }; } -export function readerViewportMargin(containerWidth: number): number { - return containerWidth < 640 ? 16 : containerWidth < 1024 ? 32 : 50; -} +export { readerViewportMargin } from '@/lib/viewport-margin'; async function fetchDemoEpubData(): Promise { demoEpubBlobPromise ??= fetch(demoEpubUrl).then(async (resp) => { diff --git a/apps/reader/src/hooks/use-scroll-annotations.ts b/apps/reader/src/hooks/use-scroll-annotations.ts new file mode 100644 index 000000000..0b2512c9e --- /dev/null +++ b/apps/reader/src/hooks/use-scroll-annotations.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Reader } from '@ritojs/core'; +import type { + AnnotationRecord, + ResolvedAnnotation, + ScrollController, + ScrollHighlight, +} from '@ritojs/kit'; + +/** The colours the paged reader draws an annotation in when its record names none. */ +const HIGHLIGHT = 'rgba(250, 204, 21, 0.3)'; +const UNDERLINE = 'rgba(234, 88, 12, 0.85)'; +/** A record's highlight id: `annotation:` and the record's id. */ +const PREFIX = 'annotation:'; + +/** + * The book's annotations as highlights of the book read scrolled: each + * record found again in the book as it is now and resolved to the rects of + * the pages laid out, again at each rest of the strip (`rest`), since the + * pages laid out grow as the book is read, and in each layout the reader + * commits: a page is numbered anew in it (a chapter laid out alone counts + * its own), and a question asked of the one before is not answered. + */ +export function useScrollAnnotations( + reader: Reader | null, + records: readonly AnnotationRecord[], + rest: unknown, +): readonly ScrollHighlight[] { + const [highlights, setHighlights] = useState([]); + const [layouts, setLayouts] = useState(0); + useEffect( + () => + reader?.onLayoutCommitted(() => { + setLayouts((count) => count + 1); + }), + [reader], + ); + useEffect(() => { + const interactions = reader?.interactions; + if (!interactions || records.length === 0) { + setHighlights([]); + return; + } + let current = true; + void Promise.all( + records.map(async (record): Promise => { + const located = await interactions.resolveAnnotationTarget?.(record.target); + if (!located || located.level === 'orphaned') return undefined; + const { href, sourceRange } = located.target; + const found = await interactions.resolveExactSourceRange?.({ href, sourceRange }); + if (found?.status !== 'resolved') return undefined; + const underline = record.kind === 'underline'; + return { + id: `${PREFIX}${record.id}`, + color: record.color ?? (underline ? UNDERLINE : HIGHLIGHT), + zIndex: 1, + mark: underline ? 'underline' : 'fill', + rects: found.range.rects, + }; + }), + ).then((found) => { + if (current) setHighlights(found.filter((each) => each !== undefined)); + }); + return () => { + current = false; + }; + }, [reader, records, rest, layouts]); + return highlights; +} + +/** The annotation a highlight drawn by {@link useScrollAnnotations} stands for. */ +export function annotationOfHighlight( + id: string, + records: readonly AnnotationRecord[], +): AnnotationRecord | undefined { + if (!id.startsWith(PREFIX)) return undefined; + const recordId = id.slice(PREFIX.length); + return records.find((record) => record.id === recordId); +} + +/** An annotation the mouse rests on, and where its tooltip goes (screen coordinates). */ +export interface ScrollAnnotationHover { + readonly annotation: { readonly record: AnnotationRecord }; + readonly x: number; + readonly y: number; +} + +/** + * The book's annotations under the pointer read scrolled, as the paged + * reader has them: the one a tap opens to edit, the one a resting mouse + * is over. + */ +export function useScrollAnnotationPointer( + view: ScrollController | null, + records: readonly AnnotationRecord[], +) { + const [opened, setOpened] = useState | null>(null); + const [hover, setHover] = useState(null); + const known = useRef(records); + known.current = records; + useEffect(() => { + setHover(null); + if (!view) return; + const stops = [ + view.on('tap', ({ highlight }) => { + const record = highlight ? annotationOfHighlight(highlight.id, known.current) : undefined; + if (!record) return; + setHover(null); + setOpened({ id: record.id, record }); + }), + view.on('highlightHover', ({ highlight }) => { + const record = highlight ? annotationOfHighlight(highlight.id, known.current) : undefined; + setHover( + record && highlight + ? { + annotation: { record }, + x: highlight.bounds.x + highlight.bounds.width / 2, + y: highlight.bounds.y, + } + : null, + ); + }), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, [view]); + const close = useCallback(() => { + setOpened(null); + }, []); + return { opened, hover, close }; +} diff --git a/apps/reader/src/hooks/use-scroll-mode.ts b/apps/reader/src/hooks/use-scroll-mode.ts new file mode 100644 index 000000000..c54a6ebc1 --- /dev/null +++ b/apps/reader/src/hooks/use-scroll-mode.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import type { TocEntry } from '@ritojs/core'; +import type { ReaderController } from '@ritojs/kit'; +import { useRitoScrollView } from '@ritojs/react'; + +interface ScrollModeOptions { + /** Whether the book is read scrolled now. */ + readonly active: boolean; + /** How the book is paged again once it is not. */ + readonly paged: 'single' | 'double'; +} + +const SCROLL_VIEW = { a11y: { enabled: true } } as const; + +/** + * The book the pages show, read scrolled while the mode is on: the same + * reader, handed from the pages to a scroll view and back. Size, theme and + * typography reach it through the pages, which lay it out for the view. + */ +export function useScrollMode( + pages: ReaderController | null, + { active, paged }: ScrollModeOptions, +) { + const scroll = useRitoScrollView(pages, { active, paged, scroll: SCROLL_VIEW }); + const { controller } = scroll; + // The reader the view shows, while it shows one. + const reader = controller ? (pages?.reader ?? null) : null; + // The contents entry of the window under the reading line. + const [activeTocEntry, setActiveTocEntry] = useState(undefined); + useEffect(() => { + if (!controller || !reader) { + setActiveTocEntry(undefined); + return; + } + const read = (spreadIndex: number) => { + const pageIndex = reader.spreads[spreadIndex]?.leftPageIndex; + setActiveTocEntry(pageIndex === undefined ? undefined : reader.findActiveTocEntry(pageIndex)); + }; + read(controller.currentSpread); + return controller.on('window', ({ spreadIndex }) => { + read(spreadIndex); + }); + }, [controller, reader]); + return { ...scroll, reader, isLoaded: controller !== null, activeTocEntry }; +} diff --git a/apps/reader/src/hooks/use-scroll-search.ts b/apps/reader/src/hooks/use-scroll-search.ts new file mode 100644 index 000000000..4b159fef8 --- /dev/null +++ b/apps/reader/src/hooks/use-scroll-search.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Reader, SearchOptions, SearchResult } from '@ritojs/core'; +import type { ScrollController, ScrollHighlight } from '@ritojs/kit'; + +/** The search result the reader is on, drawn over the text of a scrolled view. */ +const ACTIVE_COLOR = 'rgba(249, 115, 22, 0.6)'; + +interface ScrolledBook { + readonly controller: ScrollController | null; + readonly reader: Reader | null; +} + +/** + * Search in the book read scrolled, shaped as the paged reader's search: the + * reader's own search, and the result the reader is on shown at the reading + * line and given as the highlight to draw over its window. + */ +export function useScrollSearch({ controller, reader }: ScrolledBook) { + const [query, setQueryState] = useState(''); + const [results, setResults] = useState([]); + const [activeIndex, setActiveIndex] = useState(-1); + const asked = useRef(0); + const setQuery = useCallback( + (next: string, options?: SearchOptions) => { + setQueryState(next); + const request = ++asked.current; + if (!reader?.search || next.length === 0) { + setResults([]); + setActiveIndex(-1); + return; + } + void Promise.resolve(reader.search(next, options)).then((found) => { + if (request !== asked.current) return; + setResults(found); + setActiveIndex(found.length > 0 ? 0 : -1); + }); + }, + [reader], + ); + const goTo = useCallback((index: number) => { + setActiveIndex(index); + }, []); + const step = useCallback( + (by: number) => { + if (results.length === 0) return; + setActiveIndex((index) => (index + by + results.length) % results.length); + }, + [results.length], + ); + const clear = useCallback(() => { + asked.current += 1; + setQueryState(''); + setResults([]); + setActiveIndex(-1); + }, []); + const highlight = useActiveResult(controller, reader, results[activeIndex]); + return { + highlight, + query, + results, + activeIndex, + activeResult: results[activeIndex], + isActive: results.length > 0, + setQuery, + next: useCallback(() => { + step(1); + }, [step]), + prev: useCallback(() => { + step(-1); + }, [step]), + goTo, + clear, + }; +} + +/** Shows the result the reader is on, and its highlight; none, nothing. */ +function useActiveResult( + controller: ScrollController | null, + reader: Reader | null, + result: SearchResult | undefined, +): ScrollHighlight | null { + const [highlight, setHighlight] = useState(null); + useEffect(() => { + setHighlight(null); + const source = result?.source; + if (!controller || source?.status !== 'resolved') return; + const { href, sourceRange } = source; + let current = true; + void controller.scrollToLocator({ href, sourceRange }); + void reader?.interactions?.resolveExactSourceRange?.({ href, sourceRange }).then((found) => { + if (!current || found?.status !== 'resolved') return; + setHighlight({ id: 'search', color: ACTIVE_COLOR, zIndex: 2, rects: found.range.rects }); + }); + return () => { + current = false; + }; + }, [controller, reader, result]); + return highlight; +} diff --git a/apps/reader/src/hooks/use-typography.ts b/apps/reader/src/hooks/use-typography.ts index c175d6066..fc4a5ecf4 100644 --- a/apps/reader/src/hooks/use-typography.ts +++ b/apps/reader/src/hooks/use-typography.ts @@ -126,12 +126,17 @@ export function useApplyTypography( } if (applied.current.typography === typography) return; applied.current = { controller, typography }; - controller.setTypography({ - fontSize: typography.fontSize, - lineHeight: typography.lineHeightActive ? typography.lineHeight : null, - lineHeightForce: typography.lineHeightActive && typography.lineHeightForce, - fontFamily: typography.fontFamily, - fontFamilyForce: typography.fontFamilyForce, - }); + controller.setTypography(typographySettings(typography)); }, [controller, typography, openedWith]); } + +/** The settings `setTypography` takes for `typography`, on the controller or the reader. */ +export function typographySettings(typography: Typography) { + return { + fontSize: typography.fontSize, + lineHeight: typography.lineHeightActive ? typography.lineHeight : null, + lineHeightForce: typography.lineHeightActive && typography.lineHeightForce, + fontFamily: typography.fontFamily, + fontFamilyForce: typography.fontFamilyForce, + }; +} diff --git a/apps/reader/src/lib/viewport-margin.ts b/apps/reader/src/lib/viewport-margin.ts new file mode 100644 index 000000000..2a27089e4 --- /dev/null +++ b/apps/reader/src/lib/viewport-margin.ts @@ -0,0 +1,4 @@ +/** The page margin the reader app reads a book with in a view `containerWidth` wide. */ +export function readerViewportMargin(containerWidth: number): number { + return containerWidth < 640 ? 16 : containerWidth < 1024 ? 32 : 50; +} diff --git a/apps/reader/src/scroll-demo.ts b/apps/reader/src/scroll-demo.ts new file mode 100644 index 000000000..7eb161532 --- /dev/null +++ b/apps/reader/src/scroll-demo.ts @@ -0,0 +1,59 @@ +// A scrolled reading page beside the reader app: one book, its windows end +// to end, moved by the kit's scroll controller. It is what scrolled reading +// is checked on until the app reads that way itself. +import { createReader, type Reader } from '@ritojs/core'; +import { createScrollController, type ScrollController } from '@ritojs/kit'; +import { readerViewportMargin } from './hooks/use-reader'; +import { loadProductionPinnedFontPolicy } from './lib/production-pinned-font-policy'; + +interface Opened { + readonly reader: Reader; + readonly controller: ScrollController; +} + +const view = document.querySelector('#view'); +const input = document.querySelector('#open'); +const progress = document.querySelector('#progress'); +const selected = document.querySelector('#selected'); +let opened: Opened | undefined; + +async function open(file: File): Promise { + if (!view) return; + if (opened) { + opened.controller.dispose(); + await opened.reader.dispose(); + } + const canvas = document.createElement('canvas'); + const reader = await createReader(await file.arrayBuffer(), canvas, { + width: view.clientWidth, + height: view.clientHeight, + margin: readerViewportMargin(view.clientWidth), + spread: 'scrolled', + devicePixelRatio: window.devicePixelRatio, + pinnedFontPolicy: await loadProductionPinnedFontPolicy(), + }); + const controller = createScrollController(reader, canvas, { a11y: { enabled: true } }); + // A link into the book goes to where it points; the rest is left alone. + controller.on('tap', ({ target }) => { + if (target?.targetLocator) void controller.scrollToLocator(target.targetLocator); + }); + controller.on('scroll', (scrolled) => { + if (progress) progress.textContent = `${(100 * scrolled.progress).toFixed(1)}%`; + }); + controller.on('selection', ({ text, selecting }) => { + if (selected) selected.textContent = selecting ? '' : text.slice(0, 80); + }); + controller.mount(view); + opened = { reader, controller }; + // Headless checks drive and read the view directly. + Object.assign(window, { __ritoScroll: opened }); + view.dataset['loaded'] = 'true'; +} + +input?.addEventListener('change', () => { + const file = input.files?.[0]; + if (file) void open(file); +}); +window.addEventListener('resize', () => { + if (opened && view) opened.reader.resize(view.clientWidth, view.clientHeight); +}); diff --git a/apps/reader/tests/e2e/reader-selection.e2e.test.ts b/apps/reader/tests/e2e/reader-selection.e2e.test.ts index 48d6a7846..8d931cb01 100644 --- a/apps/reader/tests/e2e/reader-selection.e2e.test.ts +++ b/apps/reader/tests/e2e/reader-selection.e2e.test.ts @@ -454,6 +454,9 @@ async function expectSelectionFontBox( const shell = page.getByTestId('reader-shell'); const modelHeight = Number(await shell.getAttribute('data-selection-first-rect-height')); const modelTop = Number(await shell.getAttribute('data-selection-first-rect-y')); + await expect + .poll(async () => (await readSelectionHighlightBands(page)).length) + .toBeGreaterThanOrEqual(1); const visual = requireBand(await readSelectionHighlightBands(page), 0); expect(modelHeight).toBeGreaterThan(0); diff --git a/crates/rito-block/src/annotations.rs b/crates/rito-block/src/annotations.rs new file mode 100644 index 000000000..cdc00a36d --- /dev/null +++ b/crates/rito-block/src/annotations.rs @@ -0,0 +1,189 @@ +//! Annotation space across block edges (Blink's block-start annotation +//! space): a paragraph's first-line annotations (ruby, emphasis marks) +//! reach into the space above the line before the line grows — the box's +//! top padding, the collapsed margins above it and the leading the +//! preceding block leaves under its last line's text — unless a top +//! border or the box's own formatting context closes it off. A line that opens a resumed +//! fragment has none of that space: margins meeting the break are gone +//! and the line before it sits on another page. + +use crate::*; + +/// How the first line a paragraph fragment places takes its ruby growth. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum LineOpening { + /// The paragraph's first fragment: the annotations spend what the + /// preceding line lends (`annotation_lent`) and `space` above the line. + Start { space: f64 }, + /// A resumed fragment: nothing above lends space. + Resumed, +} + +impl LineOpening { + /// The opening for `node`'s fragment: `space` above it when it is the + /// paragraph's first, none when it resumes. + pub(crate) fn for_flow(resumed: bool, space: f64) -> Self { + if resumed { + Self::Resumed + } else { + Self::Start { space } + } + } + + /// `line` as the first line of its fragment, and how much taller that + /// made it. The growth moves the line's baseline and content down + /// inside a box grown by as much; the line's top stays. + pub(crate) fn open(self, line: &Fragment) -> (Fragment, f64) { + let Fragment::Line(fragment) = line else { + return (line.clone(), 0.0); + }; + let growth = match self { + Self::Start { space } => { + (fragment.annotation_growth - fragment.annotation_lent - space).max(0.0) + } + Self::Resumed => fragment.annotation_overflow, + }; + let delta = growth - fragment.annotation_growth; + if delta == 0.0 { + return (line.clone(), 0.0); + } + let mut opened = fragment.clone(); + opened.rect.height += delta; + opened.baseline += delta; + opened.annotation_growth = growth; + if let Some(marker) = &mut opened.marker { + marker.y += delta; + } + for child in &mut opened.children { + let rect = child.rect(); + set_fragment_position(child, rect.x, rect.y + delta); + } + (Fragment::Line(opened), delta) + } +} + +/// What an in-flow block leaves for the annotations of the paragraph +/// after it: its last line, and the space from that line's box down to +/// the block's bottom edge (bottom padding, whatever a fixed or minimum +/// height adds past the content, and the empty blocks after the line). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct AnnotationEnd { + pub(crate) line: PrecedingLine, + pub(crate) space: f64, +} + +/// The annotation end of a placed in-flow block fragment, or none when a +/// bottom border or the block's own formatting context closes it, or the +/// block does not end with a line box (Blink's block-end annotation +/// space). +pub(crate) fn block_annotation_end( + tree: &FormattingTree, + fragment: &Fragment, +) -> Result, LayoutError> { + let Fragment::Box(block) = fragment else { + return Ok(None); + }; + let style = container_layout_style(tree, block.source)?; + if is_flow_root(style) || style.flow_border_widths().block_end.get() > 0.0 { + return Ok(None); + } + for child in block.children.iter().rev() { + let end = match child { + // Annotations reaching under a block's last line stay in the + // block: what follows reuses none of the space past them. + Fragment::Line(line) if line.annotation_space_end < 0.0 => None, + Fragment::Line(line) => Some(AnnotationEnd { + line: PrecedingLine { + below: (line.rect.height - line.baseline).max(0.0), + fonts: line.fonts, + half_leading: flow_font_size(tree, line.source)? + .map_or(0.0, |size| line.glyphless_end_space(size)), + annotation_space: line.annotation_space_end, + }, + space: 0.0, + }), + Fragment::Box(inner) => { + let inner_style = container_layout_style(tree, inner.source)?; + // Floats and positioned boxes are out of flow: the block + // ends with the in-flow content before them. + if inner_style.float != Float::None || inner_style.position == Position::Absolute { + continue; + } + match block_annotation_end(tree, child)? { + Some(end) => Some(end), + // An empty block passes the space before it on. + None if passes_annotation_space(tree, child)? => continue, + None => None, + } + } + Fragment::Text(_) | Fragment::Image(_) => None, + }; + let rect = child.rect(); + return Ok(end.map(|end| AnnotationEnd { + space: end.space + (block.rect.height - (rect.y + rect.height)).max(0.0), + ..end + })); + } + Ok(None) +} + +/// Whether a placed in-flow block hands the annotation space before it on +/// to what follows, its own block size added: one holding no line box +/// whose block size is not zero, closed off by neither a block-end border +/// nor a formatting context of its own (Blink's block-end annotation space +/// of a block with no content: the space it was given plus its block +/// size; a self-collapsing block leaves none). +pub(crate) fn passes_annotation_space( + tree: &FormattingTree, + fragment: &Fragment, +) -> Result { + fn holds_line(fragment: &Fragment) -> bool { + match fragment { + Fragment::Line(_) => true, + Fragment::Box(block) => block.children.iter().any(holds_line), + Fragment::Text(_) | Fragment::Image(_) => false, + } + } + let Fragment::Box(block) = fragment else { + return Ok(false); + }; + if block.rect.height <= 0.0 || holds_line(fragment) { + return Ok(false); + } + let style = container_layout_style(tree, block.source)?; + Ok(!is_flow_root(style) && style.flow_border_widths().block_end.get() == 0.0) +} + +/// Whether an inline flow holds any annotation: a ruby annotation, or +/// text that takes emphasis marks. +pub(crate) fn flow_has_annotations( + tree: &FormattingTree, + items: &[rito_fragment::InlineItem], +) -> bool { + let styles = tree.styles(); + items.iter().any(|item| match item { + rito_fragment::InlineItem::Text { + ruby_annotation: Some(_), + .. + } => true, + rito_fragment::InlineItem::Text { style, .. } => styles + .and_then(|styles| styles.inline.style(*style).ok()) + .is_some_and(|style| style.text_flow.text_emphasis.is_some()), + _ => false, + }) +} + +/// The font size of `node`'s inline flow, as its strut style gives it. +fn flow_font_size( + tree: &FormattingTree, + node: FormattingNodeId, +) -> Result, LayoutError> { + let (Some(styles), Some(strut)) = (tree.styles(), tree.strut_style(node)) else { + return Ok(None); + }; + let style = styles + .inline + .style(strut) + .map_err(|error| LayoutError::Invalid(error.to_string()))?; + Ok(Some(f64::from(style.font.size.get()))) +} diff --git a/crates/rito-block/src/context.rs b/crates/rito-block/src/context.rs index e35d8c1c9..7ae89db0f 100644 --- a/crates/rito-block/src/context.rs +++ b/crates/rito-block/src/context.rs @@ -7,8 +7,14 @@ use crate::*; impl BlockFormattingContext { - /// Creates a block context that lays inline flows out with `inline`. - pub fn new(inline: I) -> Self { + /// Creates a block context that lays inline flows out with `inline`, + /// handing it the block layout its atomic inlines' block containers + /// need. + pub fn new(mut inline: I) -> Self { + inline.adopt_block_layout(BlockLayoutDelegate { + layout: layout_atomic_block, + intrinsic_inline_sizes: atomic_block_sizes, + }); Self { inline, inline_cache: RefCell::new(FragmentCache::new(INLINE_CACHE_BUDGET_BYTES)), @@ -21,6 +27,27 @@ impl BlockFormattingContext { } } +/// Lays an atomic inline's block container out for the inline provider +/// holding it, in a block context borrowing that provider. +fn layout_atomic_block( + inline: &dyn FormattingContext, + tree: &FormattingTree, + node: FormattingNodeId, + space: &ConstraintSpace, + cancel: &CancelFlag, +) -> Result { + BlockFormattingContext::new(inline).layout(tree, node, space, None, cancel) +} + +/// The intrinsic inline sizes of an atomic inline's block container. +fn atomic_block_sizes( + inline: &dyn FormattingContext, + tree: &FormattingTree, + node: FormattingNodeId, +) -> Result { + BlockFormattingContext::new(inline).intrinsic_inline_sizes(tree, node) +} + impl FormattingContext for BlockFormattingContext { fn layout( &self, @@ -32,7 +59,7 @@ impl FormattingContext for BlockFormattingContext { ) -> Result { match &tree.node(node).content { FormattingNodeContent::BlockContainer => { - self.layout_container(tree, node, space, token, cancel, true, false) + self.layout_container(tree, node, space, token, cancel, true, None) } FormattingNodeContent::InlineFlow { .. } => { // `space.inline_size` is the border-box width by contract @@ -52,18 +79,21 @@ impl FormattingContext for BlockFormattingContext { // line's available advance by 1/64 and skewed every // justify share on b19's note paragraphs. let grid = |px: f64| (px * 64.0).trunc() / 64.0; - let padding_left = grid(pad(style.padding.left)); - let padding_right = grid(pad(style.padding.right)); - let padding_top = pad(style.padding.top); - let padding_bottom = pad(style.padding.bottom); + let padding = style.flow_padding(); + let padding_left = grid(pad(padding.inline_start)); + let padding_right = grid(pad(padding.inline_end)); + let padding_top = pad(padding.block_start); + let padding_bottom = pad(padding.block_end); // A paragraph with a specified height flows at that // border-box height exactly like a block container: short // content leaves empty space, tall lines overflow visibly // (b74's title pill: `height: 30px; padding-top: 1em` flows // 54px tall around a 20.8px line). let fixed_height = resolve_fixed_height(style, padding_top + padding_bottom)?; + let bounds = resolve_block_size_bounds(style, padding_top + padding_bottom); if padding_left + padding_right + padding_top + padding_bottom == 0.0 && fixed_height.is_none() + && bounds.is_unbounded() { // The flow's own block is its children's containing // block; with an auto height it is indefinite, so a @@ -93,7 +123,9 @@ impl FormattingContext for BlockFormattingContext { // height (border-box minus the vertical padding). containing_block_size: fixed_height .map(|fixed| (fixed - padding_top - padding_bottom).max(0.0)), - line_text: space.line_text, + // Its lines start below the padding it leads with. + line_text: space.line_text.below(leading), + preceding_line: None, }; let mut outcome = self.inline.layout(tree, node, &sub_space, token, cancel)?; // The sizes it holds for are its content box's, not this @@ -110,16 +142,36 @@ impl FormattingContext for BlockFormattingContext { "inline provider must produce a box fragment root".to_owned(), )); }; + // Laid out on its own, the flow is its own formatting + // context: its first line's annotations reach into its top + // padding and no further. + let border_top = f64::from(style.flow_border_widths().block_start.get()); + let opening = + LineOpening::for_flow(token.is_some(), (padding_top - border_top).max(0.0)); + let mut opening_delta = 0.0; + let mut opened = false; + for child in &mut root.children { + if !opened && matches!(child, Fragment::Line(_)) { + let (line, delta) = opening.open(child); + *child = line; + opening_delta = delta; + opened = true; + } else if opening_delta != 0.0 { + let rect = child.rect(); + set_fragment_position(child, rect.x, rect.y + opening_delta); + } + } for child in &mut root.children { let rect = child.rect(); set_fragment_position(child, rect.x + padding_left, rect.y + leading); } root.rect.width = space.inline_size; - root.rect.height += leading + trailing; - if let Some(fixed) = fixed_height { - if outcome.continuation.is_none() { + root.rect.height += leading + trailing + opening_delta; + if outcome.continuation.is_none() { + if let Some(fixed) = fixed_height { root.rect.height = fixed; } + root.rect.height = bounds.clamp(root.rect.height); } Ok(outcome) } @@ -129,7 +181,7 @@ impl FormattingContext for BlockFormattingContext { )), FormattingNodeContent::Table => { let fill = matches!( - container_layout_style(tree, node)?.width, + container_layout_style(tree, node)?.inline_size(), PreferredSize::Value(_) ); let fragment = self.layout_table(tree, node, space.inline_size, fill, cancel)?; diff --git a/crates/rito-block/src/flow.rs b/crates/rito-block/src/flow.rs index 1bfc17dcf..24f102b55 100644 --- a/crates/rito-block/src/flow.rs +++ b/crates/rito-block/src/flow.rs @@ -24,7 +24,9 @@ impl BlockFormattingContext { // The parent already joined this container's first-in-flow-child // top-margin chain into its own collapse set (§8.3.1 through- // collapse); the chain is spent and must not re-apply inside. - leading_margin_consumed: bool, + // It carries the collapsed margin, which the first child's ruby + // annotations may still reach into. + leading_margin_consumed: Option, ) -> Result { let mut positioned = Vec::new(); let mut outcome = self.layout_container_flow( @@ -38,9 +40,28 @@ impl BlockFormattingContext { &mut positioned, )?; positioned.append(&mut outcome.positioned); + // The container began in an earlier fragmentainer when it resumes, + // and continues in a later one when it breaks before or inside a + // child or past its own block size (not when only floats it could + // not hold run on past its end). + if let Fragment::Box(root) = &mut outcome.fragments.root { + root.cuts = BlockCuts { + start: token.is_some(), + end: outcome.continuation.as_ref().is_some_and(|continuation| { + !continuation.resume_path.is_empty() + || matches!(continuation.stage, BreakTokenStage::Inside { .. }) + }), + }; + } let style = container_layout_style(tree, container)?; if style.position != Position::Static || container == tree.root() { - self.place_positioned(tree, &mut outcome, positioned, cancel)?; + // An unpositioned root is the initial containing block: one + // viewport, in a continuous layout that knows its fragmentainer. + let initial_height = (style.position == Position::Static + && space.fragmentainer_remaining.is_none()) + .then_some(space.fragmentainer_size) + .flatten(); + self.place_positioned(tree, &mut outcome, positioned, initial_height, cancel)?; } else { outcome.positioned = positioned; } @@ -59,7 +80,7 @@ impl BlockFormattingContext { token: Option<&BreakToken>, cancel: &CancelFlag, collapse_root_edges: bool, - leading_margin_consumed: bool, + leading_margin_consumed: Option, positioned: &mut Vec, ) -> Result { let children = tree.node(container).children.clone(); @@ -80,10 +101,14 @@ impl BlockFormattingContext { .max(0.0) / 64.0 }; - let content_left = pad(container_style.padding.left); - let padding_right = pad(container_style.padding.right); - let container_padding_top = pad(container_style.padding.top); - let container_padding_bottom = pad(container_style.padding.bottom); + // The container's flow: its children's margins, floats and + // clearance read on the edges of its lines. + let writing = container_style.writing_direction(); + let padding = container_style.flow_padding(); + let content_left = pad(padding.inline_start); + let padding_right = pad(padding.inline_end); + let container_padding_top = pad(padding.block_start); + let container_padding_bottom = pad(padding.block_end); let content_width = (space.inline_size - content_left - padding_right).max(0.0); // Resumed fragmentainers start flush: top padding belongs to the // container's first fragment only, like a truncated margin. @@ -97,20 +122,44 @@ impl BlockFormattingContext { // (a 1em heading margin starts the whole book 22px lower). It // belongs to the first fragment only; a resumed fragmentainer // starts flush like any margin meeting an unforced break. + let mut root_margin = 0.0; if collapse_root_edges && !resumed { - let (root_margin_top, _) = vertical_margins(tree, container, space.inline_size)?; + let (root_margin_top, _) = + vertical_margins(tree, container, writing, space.inline_size)?; if root_margin_top > 0.0 { y += root_margin_top; remaining -= root_margin_top; + root_margin = root_margin_top; } } // Padding blocks parent-child margin collapse per CSS, so a padded // root keeps its first child's top margin inside. let collapse_root_edges = collapse_root_edges && container_padding_top == 0.0; + // The collapsed margin above the content edge, which the first + // in-flow child's annotations may reach into (Blink's margin + // strut): what the parent spent of the leading chain, or the + // root's folded margin. + let mut leading_strut = match leading_margin_consumed { + _ if resumed => None, + Some(strut) => Some(strut), + None if collapse_root_edges && root_margin > 0.0 => Some(root_margin), + None if container == tree.root() && tree.root_leading_margin() > 0.0 => { + Some(tree.root_leading_margin()) + } + None => None, + }; + // What the previous in-flow sibling leaves for the next paragraph's + // annotations; a fragmentainer starts with none. + let mut annotation_end: Option = None; let mut fragments = Vec::new(); // The margin below the previous in-flow child, awaiting collapse // with the next child's top margin. let mut pending_margin = PendingMargin::ZERO; + // After a break before a child, the margins adjoining the break + // are discarded up to the first child with content: self-collapsing + // children pass the discard on instead of a margin (Blink's margin + // strut discards margins at a break). + let mut discarding_margins = false; // The margin set a clearing self-collapsing spacer swallowed: // the margins collapsing after it resolve against the cleared // line MINUS this amount, floored at zero (follower top = float @@ -119,7 +168,7 @@ impl BlockFormattingContext { let mut clear_credit = 0.0_f64; // Arms until the first in-flow child; a resumed container starts // flush anyway (margins truncate at unforced breaks). - let mut leading_margin_armed = leading_margin_consumed && !resumed; + let mut leading_margin_armed = leading_margin_consumed.is_some() && !resumed; // A `break-after: always` on the previous in-flow child forces a // fragmentainer break before the next one. let mut pending_forced_break = false; @@ -144,14 +193,15 @@ impl BlockFormattingContext { } let child_id = float_break.child; let child_style = container_layout_style(tree, child_id)?; - let hbox = self.resolve_float_box(tree, child_id, child_style, content_width)?; - let margin_right = match child_style.margin.right { + let hbox = + self.resolve_float_box(tree, child_id, child_style, writing, content_width)?; + let margin_right = match child_style.margin_in(writing).inline_end { LengthPercentageOrAuto::Auto => 0.0, LengthPercentageOrAuto::Value(value) => { resolve_length_percentage(value, content_width) } }; - let (_, bottom_margin) = vertical_margins(tree, child_id, content_width)?; + let (_, bottom_margin) = vertical_margins(tree, child_id, writing, content_width)?; let occupy_width = hbox.x + hbox.border_width + margin_right; let page_bottom = y + remaining.max(0.0); let fy = floats.probe_y(occupy_width, y, content_width); @@ -162,7 +212,8 @@ impl BlockFormattingContext { fragmentainer_size: space.fragmentainer_size, float_band: None, containing_block_size: None, - line_text: space.line_text, + line_text: space.line_text.whole(), + preceding_line: None, }; let outcome = self.layout(tree, child_id, &sub_space, Some(&float_break.token), cancel)?; @@ -181,7 +232,7 @@ impl BlockFormattingContext { head_height + bottom_margin.max(0.0) }; let (fx, fy) = floats.place( - child_style.float, + flow_float(child_style.float, writing), occupy_width, occupy_height, y, @@ -196,6 +247,7 @@ impl BlockFormattingContext { height: head_height, }, children: child_root.children, + cuts: child_root.cuts, })); adopt_positioned( positioned, @@ -236,13 +288,14 @@ impl BlockFormattingContext { }); continue; } - let (top_margin, bottom_margin) = vertical_margins(tree, *child_id, content_width)?; + let (top_margin, bottom_margin) = + vertical_margins(tree, *child_id, writing, content_width)?; // §8.3.1 through-collapse, layout half: an in-flow child's top // margin joins its first-in-flow-descendant chain (whatever // the bridge could not fold — percentages resolve only now). // When the parent consumed THIS container's leading chain, // the first in-flow child's whole set is spent. - let top_set = through_collapsed_top(tree, *child_id, content_width)?; + let top_set = through_collapsed_top(tree, *child_id, writing, content_width)?; // The pre-consumption set: a cleared spacer's credit is what // its chain would have collapsed to, whether or not the // parent already spent the leading chain. @@ -261,14 +314,15 @@ impl BlockFormattingContext { // content edges, never advance `y`, and never split. In-flow // margins collapse straight through them. if child_style.float != Float::None { - let hbox = self.resolve_float_box(tree, *child_id, child_style, content_width)?; + let hbox = + self.resolve_float_box(tree, *child_id, child_style, writing, content_width)?; let margin_side = |side: LengthPercentageOrAuto| match side { LengthPercentageOrAuto::Auto => 0.0, LengthPercentageOrAuto::Value(value) => { resolve_length_percentage(value, content_width) } }; - let margin_right = margin_side(child_style.margin.right); + let margin_right = margin_side(child_style.margin_in(writing).inline_end); let child_space = ConstraintSpace::continuous(hbox.border_width); // The float's own margins are THIS placement's business // (fragment y = fy + top_margin): the inner layout must @@ -285,7 +339,7 @@ impl BlockFormattingContext { None, cancel, false, - false, + None, )?, _ => self.layout(tree, *child_id, &child_space, None, cancel)?, }; @@ -320,14 +374,14 @@ impl BlockFormattingContext { || fy_probe + occupy_height <= page_bottom + 1e-6; if fits { let (fx, fy) = floats.place( - child_style.float, + flow_float(child_style.float, writing), occupy_width, occupy_height, flow_y, content_width, ); placed_floats.push(rito_fragment::EscapedFloat { - right_side: matches!(child_style.float, Float::Right), + right_side: matches!(flow_float(child_style.float, writing), Float::Right), width: occupy_width, top: fy, bottom: fy + occupy_height, @@ -341,6 +395,7 @@ impl BlockFormattingContext { height: content_height, }, children: inner_children, + cuts: BlockCuts::NONE, })); adopt_positioned( positioned, @@ -366,7 +421,8 @@ impl BlockFormattingContext { fragmentainer_size: space.fragmentainer_size, float_band: None, containing_block_size: None, - line_text: space.line_text, + line_text: space.line_text.whole(), + preceding_line: None, }; let outcome = self.layout(tree, *child_id, &sub_space, None, cancel)?; let Fragment::Box(head_root) = outcome.fragments.root else { @@ -392,7 +448,7 @@ impl BlockFormattingContext { top_margin.max(0.0) + head_root.rect.height + bottom_margin.max(0.0) }; let (fx, fy) = floats.place( - child_style.float, + flow_float(child_style.float, writing), occupy_width, occupy, flow_y, @@ -407,6 +463,7 @@ impl BlockFormattingContext { height: head_root.rect.height, }, children: head_root.children, + cuts: head_root.cuts, })); adopt_positioned( positioned, @@ -433,7 +490,7 @@ impl BlockFormattingContext { && pending_float_breaks.is_empty() { let (fx, fy) = floats.place( - child_style.float, + flow_float(child_style.float, writing), occupy_width, occupy_height, y, @@ -448,6 +505,7 @@ impl BlockFormattingContext { height: content_height, }, children: inner_children, + cuts: BlockCuts::NONE, })); adopt_positioned( positioned, @@ -459,7 +517,7 @@ impl BlockFormattingContext { } return Ok(sealed_with_break( container, - space.inline_size, + space, seal_height(y, &floats), fragments, BreakToken { @@ -487,7 +545,7 @@ impl BlockFormattingContext { { return Ok(sealed_with_break( container, - space.inline_size, + space, seal_height(y, &floats), fragments, BreakToken { @@ -505,7 +563,7 @@ impl BlockFormattingContext { // border top lands exactly at the clear position: clearance // swallows the collapsed margin whole (measured: a clearing // spacer's border top == the float's bottom, no margin gap). - let clear_to = floats.bottom_for(child_style.clear); + let clear_to = floats.bottom_for(flow_clear(child_style.clear, writing)); let swallowed_set = pending_margin.merge(original_top_set).resolve().max(0.0); let cleared = !child_resumed && clear_to > y + pending_margin.merge(top_set).resolve(); if cleared { @@ -522,7 +580,9 @@ impl BlockFormattingContext { // adjoining margins belongs; whatever the fold could not // resolve there (a percentage has no basis until layout) is // still a real margin and is applied here, as a browser does. - let gap = if child_resumed || cleared { + let discarding = discarding_margins || (child_resumed && !child_resumed_inside); + discarding_margins = false; + let gap = if child_resumed || cleared || discarding { 0.0 } else { let collapsed = pending_margin.merge(top_set).resolve(); @@ -539,7 +599,35 @@ impl BlockFormattingContext { } else { gap }; + // A child whose block start lands past the fragmentainer's end + // (an overflowing monolith or a margin before it) breaks before, + // however small, and opens the next fragmentainer: Blink's + // MovePastBreakpoint must break when the space left is below + // zero (measured: the empty `
` after an over-wide plate + // opens a column of its own). + if !child_resumed + && space.fragmentainer_remaining.is_some() + && !page_is_empty + && remaining - gap < -1.0 / 128.0 + { + return Ok(sealed_with_break( + container, + space, + seal_height(y, &floats), + fragments, + BreakToken { + resume_path: vec![*child_id], + stage: BreakTokenStage::Before, + pending_floats: std::mem::take(&mut pending_float_breaks), + }, + )); + } let available = (remaining - gap).max(0.0); + // The collapsed margin above this child, and what the sibling + // before it left, for its first line's annotations. + let leading = leading_strut.take(); + let strut = leading.map_or(gap, |strut| strut.max(gap)); + let preceding_end = annotation_end.take(); let child = tree.node(*child_id); match &child.content { FormattingNodeContent::SizedLeaf { @@ -551,7 +639,7 @@ impl BlockFormattingContext { // margin-left: 1em` occupies half the line, offset — // it does not span the container). let leaf_style = container_layout_style(tree, *child_id)?; - let leaf_box = resolve_horizontal_box(leaf_style, content_width)?; + let leaf_box = resolve_inline_box(leaf_style, writing, content_width)?; let outstanding = block_size - consumed; if outstanding <= available { y += gap; @@ -580,7 +668,7 @@ impl BlockFormattingContext { let already = block_size - (outstanding - available); return Ok(sealed_with_break( container, - space.inline_size, + space, y + available, fragments, BreakToken { @@ -597,7 +685,7 @@ impl BlockFormattingContext { // unforced break is truncated. return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -618,7 +706,7 @@ impl BlockFormattingContext { if outstanding <= fresh_capacity && available < fresh_capacity { return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -663,7 +751,7 @@ impl BlockFormattingContext { } FormattingNodeContent::InlineFlow { items } => { let child_style = container_layout_style(tree, *child_id)?; - let hbox = resolve_horizontal_box(child_style, content_width)?; + let hbox = resolve_inline_box(child_style, writing, content_width)?; if items.is_empty() && hbox.padding_top == 0.0 && hbox.padding_bottom == 0.0 @@ -685,8 +773,12 @@ impl BlockFormattingContext { height: 0.0, }, children: Vec::new(), + cuts: BlockCuts::NONE, })); - pending_margin = if cleared { + discarding_margins = discarding; + pending_margin = if discarding { + PendingMargin::ZERO + } else if cleared { // The spacer's swallowed top set must not // resurrect through the pass-through; it // credits against the next collapse instead. @@ -695,6 +787,9 @@ impl BlockFormattingContext { } else { pending_margin.merge(top_set).join(bottom_margin) }; + // Margins collapse through it: the leading strut + // still sits above the next child. + leading_strut = leading; continue; } // The paragraph's own top padding rides its first @@ -748,6 +843,29 @@ impl BlockFormattingContext { resolve_fixed_height(child_style, hbox.padding_top + hbox.padding_bottom)? .map(|fixed| (fixed - hbox.padding_top - hbox.padding_bottom).max(0.0)); let available_for_lines = (available - leading_padding).max(0.0); + // The annotation space above the first line this + // fragment places (Blink's block-start annotation + // space): the paragraph's own top padding and — unless + // a top border or its own formatting context closes it + // off — the collapsed margin above it and what the + // sibling before it left under its last line. A + // resumed fragment's first line has none. + let resumed_fragment = consumed > 0.0 || child_resumed_inside; + let border_top = f64::from(child_style.flow_border_widths().block_start.get()); + let padding_space = (hbox.padding_top - border_top).max(0.0); + let reaches_above = border_top == 0.0 && !is_flow_root(child_style); + let has_annotations = flow_has_annotations(tree, items); + let preceding = preceding_end + .filter(|_| reaches_above && has_annotations && !resumed_fragment); + // A negative margin never takes space from the padding + // (Blink clamps the space to the padding). + let opening_space = if reaches_above { + (strut + padding_space + preceding.map_or(0.0, |end| end.space)) + .max(padding_space) + } else { + padding_space + }; + let opening = LineOpening::for_flow(resumed_fragment, opening_space); // A fresh page narrowed by the box's own leading // padding is NOT force-fit territory: the padding // edge is a break opportunity, so the break lands @@ -772,15 +890,28 @@ impl BlockFormattingContext { // carry their text: `place_lines` stacks them from // where earlier fragmentainers stopped, within the // space left for lines. A pixel of slack either - // side can only add a line. + // side can only add a line. Unfragmented, the + // paragraph's lines start at its content top, + // unless a flex paragraph centres them once they + // are laid out: where they land is not known + // before, so they all carry their text. + // The first line's opening can lift every line by + // up to the space above it. match space.line_text { LineText::Omitted => LineText::Omitted, _ if available_for_lines.is_finite() => LineText::Within { top: consumed - 1.0, bottom: consumed + available_for_lines + 1.0, }, - all => all, - }, + unfragmented if centres_lines(child_style) => unfragmented.whole(), + unfragmented => unfragmented.below(paragraph_top), + } + .reaching(if has_annotations { + opening_space + } else { + 0.0 + }), + preceding.map(|end| end.line), cancel, |lines| { let squeezed = page_is_empty @@ -794,6 +925,7 @@ impl BlockFormattingContext { available_for_lines, page_is_empty && !squeezed, space.fragmentainer_size, + opening, ); (squeezed, placement) }, @@ -808,20 +940,28 @@ impl BlockFormattingContext { // (measured: Blink's 2px-only blank column // before a full-height illustration). y = layout_unit(y + gap); + // Breaking before its first line, the box keeps + // the rest of the fragmentainer (Blink breaks + // before a line box as before a block child). + let head = broken_block_size(space, y, leading_padding); fragments.push(Fragment::Box(BoxFragment { source: *child_id, rect: FragmentRect { x: content_left + hbox.x, y, width: hbox.border_width, - height: leading_padding, + height: head, }, children: Vec::new(), + cuts: BlockCuts { + start: false, + end: true, + }, })); - y = layout_unit(y + leading_padding); + y = layout_unit(y + head); return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -837,7 +977,7 @@ impl BlockFormattingContext { // resuming) with the meeting margin truncated. return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -856,7 +996,9 @@ impl BlockFormattingContext { let mut trailing_deferred = false; let had_lines = !placement.lines.is_empty(); if had_lines { - let content_height = leading_padding + (placement.consumed_end - consumed); + let content_height = leading_padding + + (placement.consumed_end - consumed) + + placement.opening_delta; let trailing_fits = content_height + hbox.padding_bottom <= remaining - gap + f64::EPSILON; // A box whose every line fits but whose trailing @@ -880,7 +1022,7 @@ impl BlockFormattingContext { { return Ok(sealed_with_break( container, - space.inline_size, + space, seal_height(y, &floats), fragments, BreakToken { @@ -903,8 +1045,16 @@ impl BlockFormattingContext { // sibling's margin stacks after it un-truncated). trailing_deferred = placement.exhausted && !trailing_fits && hbox.padding_bottom > 0.0; + // Emphasis marks reaching under the last line spend + // the bottom padding before the box grows for them + // (Blink collapses a block's end annotation + // overflow into its end padding). + let end_overflow = match placement.lines.last() { + Some(Fragment::Line(line)) => (-line.annotation_space_end).max(0.0), + _ => 0.0, + }; let trailing_padding = if placement.exhausted && trailing_fits { - hbox.padding_bottom + hbox.padding_bottom - end_overflow.min(hbox.padding_bottom) } else { 0.0 }; @@ -928,6 +1078,15 @@ impl BlockFormattingContext { }; let paragraph_height = fixed_height.unwrap_or(content_height + trailing_padding); + let paragraph_height = if whole_box_here { + resolve_block_size_bounds( + child_style, + hbox.padding_top + hbox.padding_bottom, + ) + .clamp(paragraph_height) + } else { + paragraph_height + }; // A degraded flex container with `align-items: // center` and a fixed height centers its (single // flex line of) content on the cross axis: lines @@ -935,11 +1094,7 @@ impl BlockFormattingContext { // the slack (measured on b2's landscape plates — // a 449px img in the 765px `.illus` box inks at // top+158 in the browser, exactly (765-449)/2). - let cross_center = if whole_box_here - && child_style.display.inside - == rito_style_contract::LayoutDisplayInside::Flex - && child_style.align_items == rito_style_contract::AlignItems::Center - { + let cross_center = if whole_box_here && centres_lines(child_style) { // The flex ITEM is what centers, not the line // box: a replaced item's box excludes the // strut descent the line adds below it, so a @@ -1007,6 +1162,15 @@ impl BlockFormattingContext { line }) .collect(); + // A paragraph that breaks between its lines keeps + // the rest of the fragmentainer, as Blink breaks + // before a line box; one whose lines all fit ends + // with them, its trailing padding deferred or not. + let paragraph_height = if placement.exhausted { + paragraph_height + } else { + broken_block_size(space, y, paragraph_height) + }; fragments.push(Fragment::Box(BoxFragment { source: *child_id, rect: FragmentRect { @@ -1016,6 +1180,10 @@ impl BlockFormattingContext { height: paragraph_height, }, children, + cuts: BlockCuts { + start: child_resumed_inside, + end: !placement.exhausted || trailing_deferred, + }, })); y = layout_unit(y + paragraph_height); remaining -= paragraph_height; @@ -1026,7 +1194,7 @@ impl BlockFormattingContext { // Inside continuation with every line consumed. return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -1056,16 +1224,26 @@ impl BlockFormattingContext { height: hbox.padding_bottom, }, children: Vec::new(), + cuts: BlockCuts { + start: true, + end: false, + }, })); y = layout_unit(y + hbox.padding_bottom); remaining -= hbox.padding_bottom; } + if had_lines { + annotation_end = match fragments.last() { + Some(paragraph) => block_annotation_end(tree, paragraph)?, + None => None, + }; + } pending_margin = PendingMargin::from_margin(bottom_margin); continue; } return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -1079,7 +1257,7 @@ impl BlockFormattingContext { } FormattingNodeContent::Table => { let child_style = container_layout_style(tree, *child_id)?; - let hbox = resolve_horizontal_box(child_style, content_width)?; + let hbox = resolve_inline_box(child_style, writing, content_width)?; // The table's own padding (its absorbed border included) // wraps the grid: the grid sizes columns inside the // content box and the fragment grows back to the border @@ -1087,17 +1265,34 @@ impl BlockFormattingContext { // the first fragment only; trailing rides the last. let pad_top = if child_resumed { 0.0 } else { hbox.padding_top }; let grid_width = hbox.content_width; - let placement = if space.fragmentainer_remaining.is_none() { - // Continuous flow: the table lays out whole. - TableFragmentainerPlacement::Placed { - fragment: self.layout_table( - tree, - *child_id, - grid_width, - matches!(child_style.width, PreferredSize::Value(_)), - cancel, - )?, - continuation: None, + // An inline table is an atomic inline: the line box it + // rides never breaks inside it, so it moves to the next + // fragmentainer whole when it does not fit what is left + // (monolithic content, as Blink fragments an + // `inline-table`). + let atomic = child_style.display.outside + == rito_style_contract::LayoutDisplayOutside::Inline; + let placement = if space.fragmentainer_remaining.is_none() || atomic { + let fragment = self.layout_table( + tree, + *child_id, + grid_width, + matches!(child_style.inline_size(), PreferredSize::Value(_)), + cancel, + )?; + let whole = fragment.rect.height + hbox.padding_top + hbox.padding_bottom; + let fresh_capacity = space.fragmentainer_size.unwrap_or(f64::INFINITY); + if space.fragmentainer_remaining.is_some() + && whole > available + && !page_is_empty + && available < fresh_capacity + { + TableFragmentainerPlacement::BreakBefore + } else { + TableFragmentainerPlacement::Placed { + fragment, + continuation: None, + } } } else { let child_token = if child_resumed { @@ -1109,7 +1304,7 @@ impl BlockFormattingContext { tree, *child_id, grid_width, - matches!(child_style.width, PreferredSize::Value(_)), + matches!(child_style.inline_size(), PreferredSize::Value(_)), (available - pad_top).max(0.0), space.fragmentainer_size, child_token.as_ref(), @@ -1121,7 +1316,7 @@ impl BlockFormattingContext { TableFragmentainerPlacement::BreakBefore => { return Ok(sealed_with_break( container, - space.inline_size, + space, seal_height(y, &floats), fragments, BreakToken { @@ -1158,7 +1353,8 @@ impl BlockFormattingContext { // A table shrinks to fit, so its auto margins resolve // against the used width, not the available one: this // is what centers `margin: 0 auto` tables. - let table_x = shrink_to_fit_offset(child_style, content_width, border_width); + let table_x = + shrink_to_fit_offset(child_style, writing, content_width, border_width); let table_height = table.rect.height + pad_top + pad_bottom; y += gap; remaining -= gap; @@ -1171,6 +1367,7 @@ impl BlockFormattingContext { height: table_height, }, children: table.children, + cuts: BlockCuts::NONE, })); y += table_height; remaining -= table_height; @@ -1192,7 +1389,7 @@ impl BlockFormattingContext { })); return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -1211,7 +1408,7 @@ impl BlockFormattingContext { } FormattingNodeContent::BlockContainer => { let child_style = container_layout_style(tree, *child_id)?; - let hbox = resolve_horizontal_box(child_style, content_width)?; + let hbox = resolve_inline_box(child_style, writing, content_width)?; let child_token = if child_resumed { descend_token(token, consumed)? } else { @@ -1230,14 +1427,23 @@ impl BlockFormattingContext { floats.band_at(y + gap, content_width) }, containing_block_size: None, - line_text: space.line_text, + line_text: space.line_text.below(y + gap), + // A block child's first line never reuses what the + // sibling before it left (Blink hands only its own + // inline content the container's block-start space). + preceding_line: None, }; // Mirror of through_collapsed_top's descent predicate: // when the child's leading chain joined THIS loop's // collapse set, its first in-flow child must start - // flush inside. - let child_leading_consumed = - !child_resumed && !is_flow_root(child_style) && hbox.padding_top == 0.0; + // flush inside. A child the break went before starts + // its fragmentainer with that whole chain truncated + // (Blink discards the margin strut at a break). + let child_leading_consumed = (!child_resumed_inside + && !is_flow_root(child_style) + && child_style.writing_mode == writing.mode + && hbox.padding_top == 0.0) + .then_some(strut); let outcome = self.layout_container( tree, *child_id, @@ -1254,7 +1460,10 @@ impl BlockFormattingContext { }; if child_root.children.is_empty() && child_root.rect.height > 0.0 - && outcome.continuation.is_some() + && outcome + .continuation + .as_ref() + .is_some_and(|inner| own_block_size_consumed(inner) == 0.0) && !child_resumed && space.fragmentainer_remaining.is_some() && !(fragmentainer_is_fresh && hold_no_block_space(&fragments)) @@ -1276,7 +1485,7 @@ impl BlockFormattingContext { // first column after an empty `
`). return Ok(sealed_with_break( container, - space.inline_size, + space, seal_height(y, &floats), fragments, BreakToken { @@ -1310,6 +1519,7 @@ impl BlockFormattingContext { height: 0.0, }, children: Vec::new(), + cuts: BlockCuts::NONE, })); adopt_positioned( positioned, @@ -1317,7 +1527,10 @@ impl BlockFormattingContext { content_left + hbox.x, y + gap, ); - pending_margin = if cleared { + discarding_margins = discarding; + pending_margin = if discarding { + PendingMargin::ZERO + } else if cleared { // The spacer's swallowed set credits against // the next collapse instead of resurrecting // through the pass-through. @@ -1342,6 +1555,7 @@ impl BlockFormattingContext { height: child_height, }, children: child_root.children, + cuts: child_root.cuts, })); y += child_height; remaining -= child_height; @@ -1366,6 +1580,34 @@ impl BlockFormattingContext { } match outcome.continuation { None => { + annotation_end = match fragments.last() { + Some(block) => match block_annotation_end(tree, block)? { + Some(end) => Some(end), + // An empty block hands on what it was + // given — what the block before it left, + // or the margin collapsed above it — its + // own size added (Blink's initial + // block-start annotation space). + None if passes_annotation_space(tree, block)? => preceding_end + .or_else(|| { + (strut > 0.0).then_some(AnnotationEnd { + line: PrecedingLine { + below: 0.0, + fonts: rito_fragment::LineFonts::None, + half_leading: 0.0, + annotation_space: 0.0, + }, + space: strut, + }) + }) + .map(|end| AnnotationEnd { + space: end.space + block.rect().height, + ..end + }), + None => None, + }, + None => None, + }; pending_margin = PendingMargin::from_margin(bottom_margin); continue; } @@ -1386,7 +1628,7 @@ impl BlockFormattingContext { })); return Ok(sealed_with_break( container, - space.inline_size, + space, y, fragments, BreakToken { @@ -1430,22 +1672,45 @@ impl BlockFormattingContext { // than the box leaves empty space (spacers); taller content // overflows the fixed box visibly — the box keeps its height and // following flow continues below it, like CSS overflow: visible. - if let Some(fixed) = resolve_fixed_height( - container_style, - container_padding_top + container_padding_bottom, - )? { - y = fixed; - } + // Its minimum and maximum block size bound it the same way. That + // size fragments like content (Blink's ComputeBlockSizeForFragment): + // earlier fragments held part of it, and what this fragmentainer + // cannot hold past the content continues on the next. + let own_consumed = token.map_or(0.0, own_block_size_consumed); + let block_padding = container_padding_top + container_padding_bottom; + let total = + resolve_fixed_height(container_style, block_padding)?.unwrap_or(own_consumed + y); + let content = y; + y = (resolve_block_size_bounds(container_style, block_padding).clamp(total) - own_consumed) + .max(0.0); + let own_break = space + .fragmentainer_remaining + .filter(|available| { + y > available + 1e-6 + && content <= available + 1e-6 + && pending_float_breaks.is_empty() + }) + .map(|available| { + let held = available.max(0.0); + y = held; + own_consumed + held + }); // Split floats still running past this fragmentainer resume on the // next one even though every in-flow child is done. - let continuation = if pending_float_breaks.is_empty() { - None - } else { - Some(BreakToken { + let continuation = match own_break { + Some(consumed_block_size) => Some(BreakToken { + resume_path: Vec::new(), + stage: BreakTokenStage::Inside { + consumed_block_size, + }, + pending_floats: Vec::new(), + }), + None if pending_float_breaks.is_empty() => None, + None => Some(BreakToken { resume_path: Vec::new(), stage: BreakTokenStage::Before, pending_floats: std::mem::take(&mut pending_float_breaks), - }) + }), }; Ok(LayoutOutcome { fragments: sealed(container, space.inline_size, y, fragments), @@ -1456,8 +1721,9 @@ impl BlockFormattingContext { }) } - /// A floated child's horizontal box: a resolvable width is used - /// directly, an auto width shrinks to fit its content (CSS 10.3.5 — + /// A floated child's inline-axis box in a flow laid out in `container`: + /// a resolvable width is used directly, an auto width shrinks to fit + /// its content (CSS 10.3.5 — /// as wide as its widest unbroken content wants, capped by the space /// left after margins and padding, never narrower than its longest /// unbreakable piece). Floats never resolve auto margins to centering. @@ -1466,16 +1732,17 @@ impl BlockFormattingContext { tree: &FormattingTree, child_id: FormattingNodeId, child_style: &LayoutFormattingStyle, + container: WritingDirection, content_width: f64, - ) -> Result { - let hbox = resolve_horizontal_box(child_style, content_width)?; + ) -> Result { + let hbox = resolve_inline_box(child_style, container, content_width)?; // `stretch` and `-webkit-fill-available` fill the available width as // an in-flow block does; the intrinsic keywords shrink to fit like // auto, which is exact for `fit-content`. if matches!( - child_style.width, + child_style.inline_size(), PreferredSize::Value(_) | PreferredSize::Stretch | PreferredSize::WebkitFillAvailable - ) || matches!(child_style.max_width, MaximumSize::Value(_)) + ) || matches!(child_style.max_inline_size(), MaximumSize::Value(_)) { return Ok(hbox); } @@ -1489,7 +1756,8 @@ impl BlockFormattingContext { // columns each carrying `margin-left: 0.2em` doubled every // inter-column gap when the margin stayed inside the fit). let resolve = |value| resolve_length_percentage(value, content_width); - let margin_used = [child_style.margin.left, child_style.margin.right] + let margins = child_style.margin_in(container); + let margin_used = [margins.inline_start, margins.inline_end] .iter() .map(|side| match side { LengthPercentageOrAuto::Auto => 0.0, @@ -1508,15 +1776,16 @@ impl BlockFormattingContext { } } }; - let padding_left = resolve(child_style.padding.left.value()).max(0.0); - let padding_right = resolve(child_style.padding.right.value()).max(0.0); + let padding = child_style.flow_padding(); + let padding_left = resolve(padding.inline_start.value()).max(0.0); + let padding_right = resolve(padding.inline_end.value()).max(0.0); let available = (content_width - margin_used - padding_left - padding_right).max(0.0); let fit = sizes .max_content .min(available) .max(sizes.min_content.min(available)); - Ok(HorizontalBox { - x: match child_style.margin.left { + Ok(InlineBox { + x: match margins.inline_start { LengthPercentageOrAuto::Auto => 0.0, LengthPercentageOrAuto::Value(value) => resolve(value), }, @@ -1541,6 +1810,7 @@ impl BlockFormattingContext { containing_block_size: Option, float_band: Option, line_text: LineText, + preceding_line: Option, cancel: &CancelFlag, read: impl FnOnce(&[Fragment]) -> R, ) -> Result { @@ -1555,6 +1825,7 @@ impl BlockFormattingContext { float_band, containing_block_size, line_text, + preceding_line, }; self.inline_cache.borrow_mut().layout_with( &self.inline, @@ -1572,3 +1843,10 @@ impl BlockFormattingContext { )? } } + +/// Whether a paragraph is a degraded flex container that centres its lines +/// on the cross axis, moving them once they are laid out. +fn centres_lines(style: &rito_style_contract::LayoutFormattingStyle) -> bool { + style.display.inside == rito_style_contract::LayoutDisplayInside::Flex + && style.align_items == rito_style_contract::AlignItems::Center +} diff --git a/crates/rito-block/src/fragments.rs b/crates/rito-block/src/fragments.rs index a3f7af731..a3d70605e 100644 --- a/crates/rito-block/src/fragments.rs +++ b/crates/rito-block/src/fragments.rs @@ -43,6 +43,7 @@ pub(crate) fn leaf_fragment( height, }, children: Vec::new(), + cuts: BlockCuts::NONE, }) } @@ -95,15 +96,33 @@ pub(crate) fn set_fragment_position(fragment: &mut Fragment, x: f64, y: f64) { } } +/// The block size of a box `top` into a container laid out in `space` +/// that breaks before or inside its content after using `used`: it +/// reaches the fragmentainer's end, or past it with what already +/// overflows. +pub(crate) fn broken_block_size(space: &ConstraintSpace, top: f64, used: f64) -> f64 { + space + .fragmentainer_remaining + .map_or(used, |end| used.max(end - top)) +} + +/// Seals a container that breaks before or inside one of its children. +/// The rest of the fragmentainer, unusable for child content after the +/// break, is still the container's (Blink's +/// `ConsumeRemainingFragmentainerSpace`): its fragment reaches the +/// fragmentainer's end, so its side borders and background run down to +/// the page bottom above a child pushed to the next page. Content that +/// already overflows the end keeps its own extent. pub(crate) fn sealed_with_break( container: FormattingNodeId, - inline_size: f64, + space: &ConstraintSpace, used_block_size: f64, fragments: Vec, token: BreakToken, ) -> LayoutOutcome { + let block_size = broken_block_size(space, 0.0, used_block_size); LayoutOutcome { - fragments: sealed(container, inline_size, used_block_size, fragments), + fragments: sealed(container, space.inline_size, block_size, fragments), continuation: Some(token), escaped_floats: Vec::new(), positioned: Vec::new(), @@ -127,6 +146,7 @@ pub(crate) fn sealed( height: used_block_size, }, children, + cuts: BlockCuts::NONE, }), } } diff --git a/crates/rito-block/src/lib.rs b/crates/rito-block/src/lib.rs index 8b1be4975..fc1d5c8d1 100644 --- a/crates/rito-block/src/lib.rs +++ b/crates/rito-block/src/lib.rs @@ -24,6 +24,16 @@ //! break token, a sizing keyword it does not resolve) fail closed with //! `LayoutError::Invalid` instead of guessing. //! +//! Layout is flow-relative throughout. A box lays its children out in its +//! own writing mode, so in this crate `x`, `left` and `width` run along +//! the lines from the inline-start edge and `y`, `top` and `height` across +//! them from the block-start edge, whichever physical sides those are; a +//! vertical-rl chapter stacks its columns "downwards" from the right edge. +//! Box styles are read flow-relative (a child's margins and offsets on the +//! edges of its container's flow, its padding, borders and sizes on its +//! own), and fragments keep these coordinates: whatever paints them maps +//! them onto the page with `rito_fragment::WritingModeConverter`. +//! //! The context is one type spread over the modules that own its parts: //! `context` (construction, the inline-outcome cache and the two //! provider entry points), `flow` (the driver that lays a container's @@ -40,16 +50,17 @@ use std::cell::RefCell; use rito_fragment::{ - BoxFragment, BreakToken, BreakTokenStage, CancelFlag, ConstraintSpace, FloatBreak, - FormattingContext, FormattingNodeContent, FormattingNodeId, FormattingTree, Fragment, - FragmentCache, FragmentRect, FragmentTree, IntrinsicInlineSizes, LayoutError, LayoutOutcome, - LineText, PendingPositioned, + BlockCuts, BlockLayoutDelegate, BoxFragment, BreakToken, BreakTokenStage, CancelFlag, + ConstraintSpace, FloatBreak, FormattingContext, FormattingNodeContent, FormattingNodeId, + FormattingTree, Fragment, FragmentCache, FragmentRect, FragmentTree, IntrinsicInlineSizes, + LayoutError, LayoutOutcome, LineText, PendingPositioned, PrecedingLine, }; use rito_style_contract::{ BoxSizing, Clear, Float, LayoutFormattingStyle, LengthPercentage, LengthPercentageOrAuto, - MaximumSize, PageBreak, Position, PreferredSize, + MaximumSize, MinimumSize, PageBreak, Position, PreferredSize, WritingDirection, }; +mod annotations; mod context; mod floats; mod flow; @@ -63,6 +74,7 @@ mod table; #[cfg(test)] mod tests; +pub(crate) use annotations::*; pub(crate) use floats::*; pub(crate) use fragments::*; pub(crate) use lines::*; @@ -94,6 +106,10 @@ struct LinePlacement { consumed_end: f64, /// Whether every remaining line of the paragraph was placed. exhausted: bool, + /// How much taller the first placed line got for the annotation space + /// above it (negative when the space let it shrink): the fragment's + /// height is its consumed extent plus this. + opening_delta: f64, } /// The UA default line constraints at a fragmentation break inside a @@ -164,19 +180,21 @@ struct PlacedFloatBox { bottom: f64, } -/// One in-flow child's resolved horizontal geometry within its containing -/// block: border-box offset and width, plus the padding that separates the -/// border box from the content area. -struct HorizontalBox { - /// Border-box x relative to the containing block's content area. +/// One in-flow child's resolved inline-axis geometry within its +/// containing block: border-box offset and inline size, plus the padding +/// that separates the border box from the content area. +struct InlineBox { + /// Border-box inline offset relative to the containing block's content + /// area. x: f64, - /// Border-box width (no borders yet, so padding plus content). + /// Border-box inline size (no borders yet, so padding plus content). border_width: f64, - /// Left padding: content-area offset inside the border box. + /// Inline-start padding: content-area offset inside the border box. padding_left: f64, - /// Content-area width available to the child's own layout. + /// Content-area inline size available to the child's own layout. content_width: f64, - /// Vertical padding, part of the child's own block size. + /// Block-start and block-end padding, part of the child's own block + /// size. padding_top: f64, padding_bottom: f64, } diff --git a/crates/rito-block/src/lines.rs b/crates/rito-block/src/lines.rs index da1a5f60c..102cf918f 100644 --- a/crates/rito-block/src/lines.rs +++ b/crates/rito-block/src/lines.rs @@ -1,8 +1,11 @@ //! Choosing which of a paragraph's already laid-out lines belong in the -//! current fragmentainer: skip what an earlier fragment consumed, take -//! as many as fit, move the break to honour the UA orphan and widow -//! counts, and shift the chosen lines into coordinates relative to the -//! paragraph fragment's top. +//! current fragmentainer: skip what an earlier fragment consumed, open +//! the first line remaining with the annotation space above it, take as +//! many as fit, move the break to honour the UA orphan and widow counts, +//! and shift the chosen lines into coordinates relative to the paragraph +//! fragment's top. + +use std::borrow::Cow; use crate::*; @@ -12,6 +15,7 @@ pub(crate) fn place_lines( remaining: f64, fragmentainer_is_empty: bool, fragmentainer_size: Option, + opening: LineOpening, ) -> LinePlacement { // Resumed lines: skip everything a previous fragmentainer consumed. // A ZERO-HEIGHT line (line-height: 0) at the paragraph start sits at @@ -21,14 +25,26 @@ pub(crate) fn place_lines( // scoped to the fresh paragraph (consumed == 0): past a real resume // boundary a zero-height line at exactly `consumed` was placed by // the previous fragment. - let pending: Vec<&Fragment> = lines + let mut pending: Vec> = lines .iter() .filter(|line| { let rect = line.rect(); rect.y + rect.height > consumed + f64::EPSILON || (consumed == 0.0 && rect.height <= 0.0) }) + .map(Cow::Borrowed) .collect(); + // The fragment's first line takes the annotation space above it; its + // place in the paragraph's own coordinates (what `consumed` counts) + // does not move. + let mut opening_delta = 0.0; + if let Some(first) = pending.first_mut() { + let (opened, delta) = opening.open(first); + if delta != 0.0 { + *first = Cow::Owned(opened); + opening_delta = delta; + } + } let total = pending.len(); let mut fit_count = 0; let mut used = 0.0_f64; @@ -91,20 +107,26 @@ pub(crate) fn place_lines( let mut placed = Vec::new(); let mut consumed_end = consumed; let mut y = 0.0_f64; - for line in pending.into_iter().take(take) { + for (index, line) in pending.into_iter().take(take).enumerate() { let rect = line.rect(); // The line's fragment-local top is the block distance consumed so // far inside this fragmentainer's paragraph chunk. - let mut shifted = line.clone(); + let mut shifted = line.into_owned(); set_fragment_y(&mut shifted, y); placed.push(shifted); y += rect.height; - consumed_end = rect.y + rect.height; + let source_height = if index == 0 { + rect.height - opening_delta + } else { + rect.height + }; + consumed_end = rect.y + source_height; } LinePlacement { lines: placed, consumed_end, exhausted: take == total, + opening_delta: if take == 0 { 0.0 } else { opening_delta }, } } diff --git a/crates/rito-block/src/margins.rs b/crates/rito-block/src/margins.rs index 1f3be2910..f39d4e34b 100644 --- a/crates/rito-block/src/margins.rs +++ b/crates/rito-block/src/margins.rs @@ -1,18 +1,21 @@ -//! Vertical margins: resolving a child's own top and bottom margins, +//! Block-axis margins: resolving a child's own block-start and block-end +//! margins, //! walking the first-descendant chain its top margin collapses through, //! and the set of adjoining margins that collapses to one value — the //! largest positive plus the most negative. use crate::*; -/// Resolved vertical margins of one in-flow child, in CSS px. +/// Resolved block-start and block-end margins of one in-flow child of a +/// container whose flow is `container`, in CSS px. /// /// Percentages resolve against the containing block's inline size (CSS -/// resolves the vertical sides against the *inline* axis) and `auto` -/// resolves to zero in block flow. +/// resolves the block-axis sides against the *inline* axis too) and +/// `auto` resolves to zero in block flow. pub(crate) fn vertical_margins( tree: &FormattingTree, node: FormattingNodeId, + container: WritingDirection, inline_size: f64, ) -> Result<(f64, f64), LayoutError> { let styles = tree.styles().ok_or_else(|| { @@ -22,9 +25,10 @@ pub(crate) fn vertical_margins( .layout .style(tree.node(node).style) .map_err(|error| LayoutError::Invalid(error.to_string()))?; + let margin = style.margin_in(container); Ok(( - resolve_margin(style.margin.top, inline_size), - resolve_margin(style.margin.bottom, inline_size), + resolve_margin(margin.block_start, inline_size), + resolve_margin(margin.block_end, inline_size), )) } @@ -41,6 +45,7 @@ pub(crate) fn vertical_margins( pub(crate) fn through_collapsed_top( tree: &FormattingTree, child: FormattingNodeId, + container: WritingDirection, containing_width: f64, ) -> Result { let styles = tree.styles().ok_or_else(|| { @@ -55,17 +60,22 @@ pub(crate) fn through_collapsed_top( let mut set = PendingMargin::ZERO; let mut node = child; let mut width = containing_width; + let mut container = container; loop { let style = style_of(node)?; - set = set.join(resolve_margin(style.margin.top, width)); + set = set.join(resolve_margin( + style.margin_in(container).block_start, + width, + )); if !matches!( tree.node(node).content, FormattingNodeContent::BlockContainer ) || is_flow_root(style) + || style.writing_mode != container.mode { break; } - let Ok(hbox) = resolve_horizontal_box(style, width) else { + let Ok(hbox) = resolve_inline_box(style, container, width) else { break; }; if hbox.padding_top != 0.0 { @@ -102,6 +112,7 @@ pub(crate) fn through_collapsed_top( break; }; width = hbox.content_width; + container = style.writing_direction(); node = first; } Ok(set) diff --git a/crates/rito-block/src/positioned.rs b/crates/rito-block/src/positioned.rs index eb5741012..12498ed31 100644 --- a/crates/rito-block/src/positioned.rs +++ b/crates/rito-block/src/positioned.rs @@ -10,18 +10,26 @@ //! The containing block is the positioned box's border box: the bridge //! folds border widths into padding, so the padding box CSS names is not //! told apart here (a containing block with borders places its positioned -//! boxes out by those widths). +//! boxes out by those widths). The tree's root, when it is not positioned +//! itself, stands for the initial containing block, which for continuous +//! media has the viewport's size (CSS 2.1 §10.1): in a continuous layout +//! that knows its fragmentainer, a box positioned in the root resolves +//! against the flow's first fragmentainer-tall stretch, not its whole +//! length. use crate::*; impl BlockFormattingContext { /// Lays every box in `pending` out against the root of `outcome`, its - /// containing block, and appends the fragments to that root. + /// containing block, and appends the fragments to that root; a block + /// as tall as `initial_height` when the root stands for the initial + /// containing block. pub(crate) fn place_positioned( &self, tree: &FormattingTree, outcome: &mut LayoutOutcome, pending: Vec, + initial_height: Option, cancel: &CancelFlag, ) -> Result<(), LayoutError> { if pending.is_empty() { @@ -32,14 +40,20 @@ impl BlockFormattingContext { "a containing block lays out as a box fragment".to_owned(), )); }; - let (width, height) = (block.rect.width, block.rect.height); + let (width, height) = ( + block.rect.width, + initial_height.unwrap_or(block.rect.height), + ); + // The containing block's flow: insets and margins name the edges + // of its lines. + let writing = container_layout_style(tree, block.source)?.writing_direction(); for pending in pending { if cancel.is_cancelled() { return Err(LayoutError::Cancelled); } block .children - .push(self.positioned_fragment(tree, pending, width, height, cancel)?); + .push(self.positioned_fragment(tree, pending, writing, width, height, cancel)?); } Ok(()) } @@ -48,6 +62,7 @@ impl BlockFormattingContext { &self, tree: &FormattingTree, pending: PendingPositioned, + writing: WritingDirection, block_width: f64, block_height: f64, cancel: &CancelFlag, @@ -57,29 +72,33 @@ impl BlockFormattingContext { LengthPercentageOrAuto::Auto => None, LengthPercentageOrAuto::Value(value) => Some(resolve_length_percentage(value, basis)), }; - // Margins and horizontal insets resolve against the block's width, - // vertical insets against its height; `auto` margins count as 0. + // Margins and inline insets resolve against the block's inline + // size, block insets against its block size; `auto` margins count + // as 0. let margin = |side: LengthPercentageOrAuto| inset(side, block_width).unwrap_or(0.0); + let insets = style.inset_in(writing); + let margins = style.margin_in(writing); let (left, right) = ( - inset(style.inset.left, block_width), - inset(style.inset.right, block_width), + inset(insets.inline_start, block_width), + inset(insets.inline_end, block_width), ); let (top, bottom) = ( - inset(style.inset.top, block_height), - inset(style.inset.bottom, block_height), + inset(insets.block_start, block_height), + inset(insets.block_end, block_height), ); - let (margin_left, margin_right) = (margin(style.margin.left), margin(style.margin.right)); - let (margin_top, margin_bottom) = (margin(style.margin.top), margin(style.margin.bottom)); - let border_width = match (style.width, left, right) { + let (margin_left, margin_right) = + (margin(margins.inline_start), margin(margins.inline_end)); + let (margin_top, margin_bottom) = (margin(margins.block_start), margin(margins.block_end)); + let border_width = match (style.inline_size(), left, right) { (PreferredSize::Value(_), _, _) => { - resolve_horizontal_box(style, block_width)?.border_width + resolve_inline_box(style, writing, block_width)?.border_width } (_, Some(left), Some(right)) => { (block_width - left - right - margin_left - margin_right).max(0.0) } _ => { let available = block_width - left.unwrap_or(0.0) - right.unwrap_or(0.0); - self.resolve_float_box(tree, pending.node, style, available.max(0.0))? + self.resolve_float_box(tree, pending.node, style, writing, available.max(0.0))? .border_width } }; @@ -93,7 +112,7 @@ impl BlockFormattingContext { let space = ConstraintSpace::continuous(border_width); let outcome = match tree.node(pending.node).content { FormattingNodeContent::BlockContainer => { - self.layout_container(tree, pending.node, &space, None, cancel, false, false)? + self.layout_container(tree, pending.node, &space, None, cancel, false, None)? } _ => self.layout(tree, pending.node, &space, None, cancel)?, }; @@ -102,7 +121,7 @@ impl BlockFormattingContext { "a positioned box lays out as a box fragment".to_owned(), )); }; - if let (PreferredSize::Auto, Some(top), Some(bottom)) = (style.height, top, bottom) { + if let (PreferredSize::Auto, Some(top), Some(bottom)) = (style.block_size(), top, bottom) { positioned.rect.height = (block_height - top - bottom - margin_top - margin_bottom).max(0.0); } diff --git a/crates/rito-block/src/resume.rs b/crates/rito-block/src/resume.rs index 9e02b8b5f..bf1295af0 100644 --- a/crates/rito-block/src/resume.rs +++ b/crates/rito-block/src/resume.rs @@ -7,8 +7,8 @@ use crate::*; /// Strips the leading path segment from a break token so a child container /// resumes at exactly the interrupted descendant. A token whose path ends -/// at the container itself is only meaningful as `Before` (handled by the -/// caller as a fresh start); `Inside` cannot address a container directly. +/// at the container itself is `Before` (handled by the caller as a fresh +/// start) or `Inside` the container's own block size, its content done. pub(crate) fn descend_token( token: Option<&BreakToken>, consumed: f64, @@ -46,11 +46,14 @@ pub(crate) fn descend_token( })); } if consumed != 0.0 { - return Err(LayoutError::Invalid( - "a break token cannot resume inside a block container without naming the \ - interrupted descendant" - .to_owned(), - )); + // Only the container's own block size continues. + return Ok(Some(BreakToken { + resume_path: Vec::new(), + stage: BreakTokenStage::Inside { + consumed_block_size: consumed, + }, + pending_floats: Vec::new(), + })); } Ok(None) } @@ -63,12 +66,13 @@ pub(crate) fn resume_point( return Ok((0, 0.0, false)); }; let Some(target) = token.resume_path.first() else { - if token.pending_floats.is_empty() { + if token.pending_floats.is_empty() && own_block_size_consumed(token) == 0.0 { return Err(LayoutError::Invalid( "break token carries an empty resume path".to_owned(), )); } - // Only split floats resume: every in-flow child already finished. + // Only split floats, or the box's own block size, resume: every + // in-flow child already finished. return Ok((children.len(), 0.0, false)); }; let index = children @@ -84,3 +88,18 @@ pub(crate) fn resume_point( } => Ok((index, consumed_block_size, true)), } } + +/// How much of a box's own block size — a fixed or minimum size past its +/// content — earlier fragments held, when only that resumes: a token with +/// no child to resume at, inside the box. +pub(crate) fn own_block_size_consumed(token: &BreakToken) -> f64 { + match (token.resume_path.is_empty(), token.stage) { + ( + true, + BreakTokenStage::Inside { + consumed_block_size, + }, + ) => consumed_block_size, + _ => 0.0, + } +} diff --git a/crates/rito-block/src/sizing.rs b/crates/rito-block/src/sizing.rs index 195461df7..ba5bd242c 100644 --- a/crates/rito-block/src/sizing.rs +++ b/crates/rito-block/src/sizing.rs @@ -1,4 +1,4 @@ -//! Turning a box's style into used sizes: the horizontal solve that +//! Turning a box's style into used sizes: the inline-axis solve that //! distributes margins, padding and width across the containing block, //! the offset a shrink-to-fit box takes, a specified height, what a box //! contributes to its container's intrinsic width, whether a container @@ -24,16 +24,17 @@ pub(crate) fn own_width_contribution( return Ok(sizes); } let style = container_layout_style(tree, node)?; + let padding = style.flow_padding(); let pad = |side: rito_style_contract::NonNegativeLengthPercentage| match side.value() { // Used paddings sit on the LayoutUnit grid (truncation). LengthPercentage::Length(px) => (f64::from(px.get()) * 64.0).trunc() / 64.0, _ => 0.0, }; - if let rito_style_contract::PreferredSize::Value(width) = style.width { + if let rito_style_contract::PreferredSize::Value(width) = style.inline_size() { if let LengthPercentage::Length(px) = width.value() { let outer = match style.box_sizing { BoxSizing::ContentBox => { - f64::from(px.get()) + pad(style.padding.left) + pad(style.padding.right) + f64::from(px.get()) + pad(padding.inline_start) + pad(padding.inline_end) } BoxSizing::BorderBox => f64::from(px.get()), }; @@ -44,17 +45,20 @@ pub(crate) fn own_width_contribution( sizes.max_content = outer; } } - // What a box contributes to its container's intrinsic width is its - // margin box (css-sizing-3 §5.2), so horizontal margins count — a + // What a box contributes to its container's intrinsic inline size is + // its margin box (css-sizing-3 §5.2), so its inline margins count — a // negative one included, which narrows the contribution. `auto` // resolves to zero here: it has no width to give until layout runs. + // Both inline margins count, so whichever end the container starts + // its lines from does not matter. let margin = |side: rito_style_contract::LengthPercentageOrAuto| match side { rito_style_contract::LengthPercentageOrAuto::Value(LengthPercentage::Length(px)) => { f64::from(px.get()) } _ => 0.0, }; - let margins = margin(style.margin.left) + margin(style.margin.right); + let margins = style.margin_in(style.writing_direction()); + let margins = margin(margins.inline_start) + margin(margins.inline_end); sizes.min_content += margins; sizes.max_content += margins; Ok(sizes) @@ -87,15 +91,17 @@ pub(crate) fn container_layout_style( .map_err(|error| LayoutError::Invalid(error.to_string())) } -/// CSS block-level horizontal resolution: `margin + padding + width = -/// containing width`. An `auto` width fills what the margins leave; a -/// specified width distributes leftover space to `auto` margins (centering +/// CSS block-level inline-axis resolution: `margin + padding + width = +/// containing width` along the lines of `container`, the containing +/// block's flow. An `auto` inline size fills what the margins leave; a +/// specified one distributes leftover space to `auto` margins (centering /// with both auto), clamped at zero so over-wide content overflows to the /// end side like a browser. -pub(crate) fn resolve_horizontal_box( +pub(crate) fn resolve_inline_box( style: &LayoutFormattingStyle, + container: WritingDirection, containing_width: f64, -) -> Result { +) -> Result { let resolve = |value: LengthPercentage| resolve_length_percentage(value, containing_width); // A used padding edge sits on the LayoutUnit grid by TRUNCATION // toward zero (LayoutUnit's float constructor), like @@ -107,10 +113,11 @@ pub(crate) fn resolve_horizontal_box( // the untruncated subtraction lands 1/64 wide and skews every // justify share on the block's lines). let edge = |value: LengthPercentage| (resolve(value) * 64.0).trunc() / 64.0; - let padding_left = edge(style.padding.left.value()).max(0.0); - let padding_right = edge(style.padding.right.value()).max(0.0); - let padding_top = edge(style.padding.top.value()).max(0.0); - let padding_bottom = edge(style.padding.bottom.value()).max(0.0); + let padding = style.flow_padding(); + let padding_left = edge(padding.inline_start.value()).max(0.0); + let padding_right = edge(padding.inline_end.value()).max(0.0); + let padding_top = edge(padding.block_start.value()).max(0.0); + let padding_bottom = edge(padding.block_end.value()).max(0.0); let margin = |side: LengthPercentageOrAuto| -> Option { match side { LengthPercentageOrAuto::Auto => None, @@ -123,9 +130,10 @@ pub(crate) fn resolve_horizontal_box( LengthPercentageOrAuto::Value(value) => Some((resolve(value) * 64.0).trunc() / 64.0), } }; - let margin_left = margin(style.margin.left); - let margin_right = margin(style.margin.right); - let width = match style.width { + let margins = style.margin_in(container); + let margin_left = margin(margins.inline_start); + let margin_right = margin(margins.inline_end); + let width = match style.inline_size() { PreferredSize::Auto => None, PreferredSize::Value(value) => Some(resolve(value.value()).max(0.0)), // Keep books readable until intrinsic/extrinsic sizing is implemented. @@ -144,7 +152,7 @@ pub(crate) fn resolve_horizontal_box( // max-width caps the used width; an auto width capped below the // available space behaves like a specified width (so auto margins can // center the capped box, the common `max-width + margin:auto` pattern). - let max_width = match style.max_width { + let max_width = match style.max_inline_size() { MaximumSize::None => None, MaximumSize::Value(value) => Some(resolve(value.value()).max(0.0)), MaximumSize::MaxContent @@ -178,7 +186,7 @@ pub(crate) fn resolve_horizontal_box( let margin_right = margin_right.unwrap_or(0.0); let border_width = (containing_width - margin_left - margin_right).max(padding_left + padding_right); - Ok(HorizontalBox { + Ok(InlineBox { x: margin_left, border_width, padding_left, @@ -199,7 +207,7 @@ pub(crate) fn resolve_horizontal_box( (None, Some(right)) => (free - right).max(0.0), (Some(left), _) => left, }; - Ok(HorizontalBox { + Ok(InlineBox { x, border_width, padding_left, @@ -212,10 +220,12 @@ pub(crate) fn resolve_horizontal_box( } /// Inline offset for a shrink-to-fit box (a table) inside its containing -/// block: auto margins share the free space the used width leaves, the -/// same distribution `resolve_horizontal_box` applies to a definite width. +/// block, whose flow is `container`: auto margins share the free space +/// the used width leaves, the same distribution `resolve_inline_box` +/// applies to a definite width. pub(crate) fn shrink_to_fit_offset( style: &LayoutFormattingStyle, + container: WritingDirection, containing_width: f64, used_width: f64, ) -> f64 { @@ -223,31 +233,33 @@ pub(crate) fn shrink_to_fit_offset( match side { LengthPercentageOrAuto::Auto => None, // Used margins truncate onto the LayoutUnit grid, matching - // `resolve_horizontal_box` and `resolve_margin`. + // `resolve_inline_box` and `resolve_margin`. LengthPercentageOrAuto::Value(value) => { Some((resolve_length_percentage(value, containing_width) * 64.0).trunc() / 64.0) } } }; let free = containing_width - used_width; - match (margin(style.margin.left), margin(style.margin.right)) { + let margins = style.margin_in(container); + match (margin(margins.inline_start), margin(margins.inline_end)) { (None, None) => (free / 2.0).max(0.0), (None, Some(right)) => (free - right).max(0.0), (Some(left), _) => left, } } -/// The container's fixed border-box height, when `height` is specified. -/// Percentages need a definite containing-block height that block flow -/// does not provide, so they resolve to `None` (auto), per CSS. +/// The container's fixed border-box block size, when its block size +/// (`height` in horizontal writing) is specified. Percentages need a +/// definite containing-block size that block flow does not provide, so +/// they resolve to `None` (auto), per CSS. pub(crate) fn resolve_fixed_height( style: &LayoutFormattingStyle, vertical_padding: f64, ) -> Result, LayoutError> { - let height = match style.height { + let height = match style.block_size() { PreferredSize::Auto => return Ok(None), PreferredSize::Value(value) => match value.value() { - LengthPercentage::Length(px) => f64::from(px.get()), + LengthPercentage::Length(px) => layout_unit_trunc(f64::from(px.get())), // No definite height basis in block flow: behaves as auto. LengthPercentage::Percentage(_) | LengthPercentage::Linear { .. } => return Ok(None), }, @@ -268,6 +280,72 @@ pub(crate) fn resolve_fixed_height( })) } +/// A box's border-box block-size bounds: its minimum and maximum block +/// size (`min-height`/`max-height` across horizontal lines, `min-width`/ +/// `max-width` across vertical ones). Percentages need a definite +/// containing-block block size that block flow does not provide, so a +/// percentage minimum is zero and a percentage maximum none (CSS 2 +/// §10.7). +#[derive(Clone, Copy, Debug)] +pub(crate) struct BlockSizeBounds { + min: f64, + max: Option, +} + +impl BlockSizeBounds { + /// The used block size of a box whose block size would otherwise be + /// `size`: capped by the maximum, then raised to the minimum, which + /// wins (`ComputeBlockSizeForFragment`). + pub(crate) fn clamp(self, size: f64) -> f64 { + self.max.map_or(size, |max| size.min(max)).max(self.min) + } + + /// Whether the bounds leave every block size as it is. + pub(crate) fn is_unbounded(self) -> bool { + self.min == 0.0 && self.max.is_none() + } +} + +pub(crate) fn resolve_block_size_bounds( + style: &LayoutFormattingStyle, + block_padding: f64, +) -> BlockSizeBounds { + let border_box = |size: f64| match style.box_sizing { + BoxSizing::ContentBox => size.max(0.0) + block_padding, + BoxSizing::BorderBox => size.max(0.0), + }; + // Each length sits on the LayoutUnit grid by truncation, as Blink + // stores a used size (measured: `min-width: 1.6em` at 16px makes a + // vertical paragraph 25.59375 wide, not 25.6, and a page of them + // ends where its lines do). + let min = match style.min_block_size() { + MinimumSize::Length(px) => border_box(layout_unit_trunc(f64::from(px.get()))), + MinimumSize::Auto | MinimumSize::Percentage(_) => 0.0, + }; + let max = match style.max_block_size() { + MaximumSize::Value(value) => match value.value() { + LengthPercentage::Length(px) => { + Some(border_box(layout_unit_trunc(f64::from(px.get())))) + } + LengthPercentage::Percentage(_) | LengthPercentage::Linear { .. } => None, + }, + MaximumSize::None + | MaximumSize::MaxContent + | MaximumSize::MinContent + | MaximumSize::FitContent + | MaximumSize::WebkitFillAvailable + | MaximumSize::Stretch + | MaximumSize::FitContentFunction(_) => None, + }; + BlockSizeBounds { min, max } +} + +/// `value` truncated onto the LayoutUnit grid (1/64 px), as LayoutUnit's +/// float constructor stores a length. +pub(crate) fn layout_unit_trunc(value: f64) -> f64 { + (value * 64.0).trunc() / 64.0 +} + pub(crate) fn resolve_length_percentage(value: LengthPercentage, basis: f64) -> f64 { match value { LengthPercentage::Length(px) => f64::from(px.get()), @@ -278,6 +356,30 @@ pub(crate) fn resolve_length_percentage(value: LengthPercentage, basis: f64) -> } } +/// The end of a flow's inline axis a float sits at, as this crate places +/// floats: `Left` at the inline-start, `Right` at the inline-end. CSS +/// `left` and `right` name the line-left and line-right sides, which are +/// the inline-start and inline-end of a left-to-right flow laid out in +/// `container` (horizontal or vertical) and the other way round in a +/// right-to-left one. +pub(crate) fn flow_float(float: Float, container: WritingDirection) -> Float { + match float { + Float::Left if container.is_rtl() => Float::Right, + Float::Right if container.is_rtl() => Float::Left, + other => other, + } +} + +/// The floats a `clear` in a flow laid out in `container` clears, on the +/// sides [`flow_float`] places them. +pub(crate) fn flow_clear(clear: Clear, container: WritingDirection) -> Clear { + match clear { + Clear::Left if container.is_rtl() => Clear::Right, + Clear::Right if container.is_rtl() => Clear::Left, + other => other, + } +} + /// Snaps a block-axis position to the LayoutUnit grid (1/64 CSS px). /// Blink performs every block advance in LayoutUnit, so all its line and /// box positions are exact 1/64 multiples; float debris here (measured: diff --git a/crates/rito-block/src/table.rs b/crates/rito-block/src/table.rs index b23e12945..6249df584 100644 --- a/crates/rito-block/src/table.rs +++ b/crates/rito-block/src/table.rs @@ -22,6 +22,7 @@ fn empty_table_fragment(table: FormattingNodeId) -> BoxFragment { height: 0.0, }, children: Vec::new(), + cuts: rito_fragment::BlockCuts::NONE, } } @@ -61,6 +62,7 @@ impl BlockFormattingContext { height: y, }, children: rows, + cuts: rito_fragment::BlockCuts::NONE, }) } @@ -202,11 +204,12 @@ impl BlockFormattingContext { // Cells keep their text: a table's cells are laid out // whole however the page around them is. line_text: LineText::All, + preceding_line: None, }, inner_token.as_ref(), cancel, false, - false, + None, )?; let Fragment::Box(mut cell_root) = outcome.fragments.root else { return Err(LayoutError::Invalid( @@ -242,6 +245,7 @@ impl BlockFormattingContext { height: fragment_height, }, children: vec![Fragment::Box(cell_root)], + cuts: rito_fragment::BlockCuts::NONE, })); y += fragment_height; match outcome.continuation { @@ -279,6 +283,7 @@ impl BlockFormattingContext { height: y, }, children: rows_out, + cuts: rito_fragment::BlockCuts::NONE, }, continuation, }) @@ -364,10 +369,13 @@ impl BlockFormattingContext { } } let table_style = container_layout_style(tree, table)?; - let (spacing_x, spacing_y) = ( + // `border-spacing` is horizontal then vertical; the grid spaces + // its columns along the table's lines and its rows across them. + let spacing = table_style.writing_direction().axes( f64::from(table_style.border_spacing.0.get()), f64::from(table_style.border_spacing.1.get()), ); + let (spacing_x, spacing_y) = (spacing.inline, spacing.block); let spacing_total = spacing_x * (column_count as f64 + 1.0); let content_available = (available_width - spacing_total).max(0.0); // Column sizing follows the CSS tables algorithm: an assignable @@ -453,7 +461,7 @@ impl BlockFormattingContext { None, cancel, false, - false, + None, )?; let Fragment::Box(mut cell_root) = outcome.fragments.root else { return Err(LayoutError::Invalid( @@ -496,6 +504,7 @@ impl BlockFormattingContext { height: row_height, }, children: cell_fragments.into_iter().map(Fragment::Box).collect(), + cuts: rito_fragment::BlockCuts::NONE, }) } @@ -518,13 +527,14 @@ impl BlockFormattingContext { LengthPercentage::Length(px) => (f64::from(px.get()) * 64.0).trunc() / 64.0, _ => 0.0, }; - let padding = pad(style.padding.left) + pad(style.padding.right); + let flow_padding = style.flow_padding(); + let padding = pad(flow_padding.inline_start) + pad(flow_padding.inline_end); // Percentages resolve against the table width, which the column // algorithm has not established yet; only definite lengths take // part here, and content sizing covers the rest. let mut specified = None; let mut percentage = None; - if let rito_style_contract::PreferredSize::Value(width) = style.width { + if let rito_style_contract::PreferredSize::Value(width) = style.inline_size() { match width.value() { LengthPercentage::Length(px) => specified = Some(f64::from(px.get()) + padding), LengthPercentage::Percentage(ratio) => { diff --git a/crates/rito-block/src/tests.rs b/crates/rito-block/src/tests.rs index 02c522b11..d1707677a 100644 --- a/crates/rito-block/src/tests.rs +++ b/crates/rito-block/src/tests.rs @@ -6,11 +6,12 @@ use rito_inline::{plain_paragraph_style, ParleyInlineContext}; use rito_style_contract::{ AlignItems, Clear, CssPx, Float, FontFamilies, FontFamily, FontFamilyName, InlineStyleTable, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, - LayoutFormattingStyle, LayoutStyleId, LayoutStyleTable, ListMarkerStyle, MaximumHeight, - MaximumSize, MinimumHeight, NonNegativeLengthPercentage, Overflow, PageBreak, Percentage, - PhysicalSides, Position, PreferredSize, + LayoutFormattingStyle, LayoutStyleId, LayoutStyleTable, ListMarkerStyle, MaximumSize, + MinimumSize, NonNegativeLengthPercentage, Overflow, PageBreak, Percentage, PhysicalSides, + Position, PreferredSize, }; +mod continuous; mod floats; mod margins; mod pagination; @@ -51,6 +52,9 @@ fn block_style( bottom: zero_padding(), left: zero_padding(), }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -59,8 +63,11 @@ fn block_style( width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -133,7 +140,11 @@ impl FormattingContext for FixedLineInline { }, baseline: 8.0, trailing_whitespace: 0.0, - ruby_growth: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, text_runs: Vec::new(), children: Vec::new(), }) @@ -150,6 +161,7 @@ impl FormattingContext for FixedLineInline { height: 10.0 * items.len() as f64, }, children: lines, + cuts: rito_fragment::BlockCuts::NONE, }), }, continuation: None, diff --git a/crates/rito-block/src/tests/continuous.rs b/crates/rito-block/src/tests/continuous.rs new file mode 100644 index 000000000..d90494b0b --- /dev/null +++ b/crates/rito-block/src/tests/continuous.rs @@ -0,0 +1,338 @@ +//! A continuous layout that knows its fragmentainer, as a scrolled chapter +//! is laid out: a box positioned in the tree's unpositioned root resolves +//! against the initial containing block, which for continuous media has +//! the viewport's size (CSS 2.1 §10.1), the flow's first fragmentainer-tall +//! stretch; and a forced break, which only fragmentation honours, is run +//! through. + +use super::*; + +/// A root holding a block `tall` px high and, positioned at the bottom of +/// the root, a box holding a block 50 px high. +fn tree_with_a_bottom_box(tall: f64) -> FormattingTree { + let mut layout = LayoutStyleTable::new(0); + let plain = layout + .intern(block_style(margin_px(0.0), margin_px(0.0))) + .expect("style interns"); + let mut bottom = block_style(margin_px(0.0), margin_px(0.0)); + bottom.position = Position::Absolute; + bottom.inset.bottom = margin_px(0.0); + let bottom = layout.intern(bottom).expect("style interns"); + let leaf = |style, block_size| FormattingNode { + style, + content: FormattingNodeContent::SizedLeaf { + block_size, + breakable: false, + }, + children: Vec::new(), + }; + let container = |style, children: Vec| FormattingNode { + style, + content: FormattingNodeContent::BlockContainer, + children: children.into_iter().map(FormattingNodeId).collect(), + }; + FormattingTree::with_styles( + vec![ + leaf(plain, tall), + leaf(plain, 50.0), + container(bottom, vec![1]), + container(plain, vec![0, 2]), + ], + FormattingNodeId(3), + FormattingTreeStyles { + layout, + inline: InlineStyleTable::new(0), + }, + ) + .expect("tree builds") +} + +fn bottom_box_top(space: &ConstraintSpace) -> f64 { + let context = BlockFormattingContext::new(FixedLineInline); + let tree = tree_with_a_bottom_box(2000.0); + let outcome = context + .layout(&tree, tree.root(), space, None, &CancelFlag::new()) + .expect("tree lays out"); + box_children(&outcome) + .last() + .expect("the positioned box joins the root") + .rect() + .y +} + +#[test] +fn a_box_positioned_in_the_root_of_a_continuous_layout_sits_in_its_first_viewport() { + let viewport = ConstraintSpace { + fragmentainer_size: Some(500.0), + ..ConstraintSpace::continuous(300.0) + }; + assert_eq!(bottom_box_top(&viewport), 450.0); + // Not knowing the fragmentainer, the root's own box is the block. + assert_eq!(bottom_box_top(&ConstraintSpace::continuous(300.0)), 1950.0); +} + +#[test] +fn a_continuous_layout_runs_on_through_a_forced_break() { + let mut layout = LayoutStyleTable::new(0); + let plain = layout + .intern(block_style(margin_px(0.0), margin_px(0.0))) + .expect("style interns"); + let mut forced = block_style(margin_px(0.0), margin_px(0.0)); + forced.break_before = PageBreak::Always; + let forced = layout.intern(forced).expect("style interns"); + let leaf = |style| FormattingNode { + style, + content: FormattingNodeContent::SizedLeaf { + block_size: 100.0, + breakable: false, + }, + children: Vec::new(), + }; + let tree = FormattingTree::with_styles( + vec![ + leaf(plain), + leaf(forced), + FormattingNode { + style: plain, + content: FormattingNodeContent::BlockContainer, + children: vec![FormattingNodeId(0), FormattingNodeId(1)], + }, + ], + FormattingNodeId(2), + FormattingTreeStyles { + layout, + inline: InlineStyleTable::new(0), + }, + ) + .expect("tree builds"); + let viewport = ConstraintSpace { + fragmentainer_size: Some(500.0), + ..ConstraintSpace::continuous(300.0) + }; + let outcome = BlockFormattingContext::new(FixedLineInline) + .layout(&tree, tree.root(), &viewport, None, &CancelFlag::new()) + .expect("tree lays out"); + assert!( + outcome.continuation.is_none(), + "a continuous layout never breaks" + ); + let tops: Vec = box_children(&outcome) + .iter() + .map(|child| child.rect().y) + .collect(); + assert_eq!(tops, [0.0, 100.0]); +} + +/// Lays lines out as [`FixedLineInline`] does, noting the line text each +/// flow is asked for. +struct NotingInline(std::rc::Rc>>); + +impl FormattingContext for NotingInline { + fn layout( + &self, + tree: &FormattingTree, + node: FormattingNodeId, + space: &ConstraintSpace, + token: Option<&BreakToken>, + cancel: &CancelFlag, + ) -> Result { + self.0.borrow_mut().push((node, space.line_text)); + FixedLineInline.layout(tree, node, space, token, cancel) + } + + fn intrinsic_inline_sizes( + &self, + tree: &FormattingTree, + node: FormattingNodeId, + ) -> Result { + FixedLineInline.intrinsic_inline_sizes(tree, node) + } +} + +fn padded_top(px: f32) -> LayoutFormattingStyle { + let mut style = block_style(margin_px(0.0), margin_px(0.0)); + style.padding.top = + NonNegativeLengthPercentage::new(LengthPercentage::Length(CssPx::new(px).expect("finite"))); + style +} + +/// The line text each flow of `tree` is asked for, laid out continuously +/// under `line_text`. +fn line_text_asked( + tree: &FormattingTree, + line_text: LineText, +) -> Vec<(FormattingNodeId, LineText)> { + let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let context = BlockFormattingContext::new(NotingInline(std::rc::Rc::clone(&asked))); + let space = ConstraintSpace { + line_text, + ..ConstraintSpace::continuous(300.0) + }; + context + .layout(tree, tree.root(), &space, None, &CancelFlag::new()) + .expect("tree lays out"); + asked.take() +} + +fn text_items(inline: &mut InlineStyleTable, count: usize) -> FormattingNodeContent { + let style = inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Named(FontFamilyName::new("Fixture"))]) + .expect("family list"), + 16.0, + 0.0, + ), + ) + .expect("style interns"); + FormattingNodeContent::InlineFlow { + items: (0..count) + .map(|line| InlineItem::Text { + text: format!("line {line}"), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }) + .collect(), + } +} + +#[test] +fn a_range_of_lines_reaches_a_nested_flow_in_its_own_coordinates() { + // A 100 px block, then a box with 20 px of top padding holding a 30 px + // block and a paragraph: the paragraph's lines start 150 px down. + let mut layout = LayoutStyleTable::new(0); + let plain = layout + .intern(block_style(margin_px(0.0), margin_px(0.0))) + .expect("style interns"); + let padded = layout.intern(padded_top(20.0)).expect("style interns"); + let mut inline = InlineStyleTable::new(1); + let leaf = |block_size| FormattingNode { + style: plain, + content: FormattingNodeContent::SizedLeaf { + block_size, + breakable: false, + }, + children: Vec::new(), + }; + let tree = FormattingTree::with_styles( + vec![ + leaf(100.0), + leaf(30.0), + FormattingNode { + style: plain, + content: text_items(&mut inline, 3), + children: Vec::new(), + }, + FormattingNode { + style: padded, + content: FormattingNodeContent::BlockContainer, + children: vec![FormattingNodeId(1), FormattingNodeId(2)], + }, + FormattingNode { + style: plain, + content: FormattingNodeContent::BlockContainer, + children: vec![FormattingNodeId(0), FormattingNodeId(3)], + }, + ], + FormattingNodeId(4), + FormattingTreeStyles { layout, inline }, + ) + .expect("tree builds"); + assert_eq!( + line_text_asked( + &tree, + LineText::Within { + top: 160.0, + bottom: 170.0 + } + ), + [( + FormattingNodeId(2), + LineText::Within { + top: 10.0, + bottom: 20.0 + } + )] + ); + assert_eq!( + line_text_asked(&tree, LineText::All), + [(FormattingNodeId(2), LineText::All)] + ); +} + +#[test] +fn a_range_of_lines_reaches_a_padded_paragraph_below_its_padding() { + let mut layout = LayoutStyleTable::new(0); + let padded = layout.intern(padded_top(12.0)).expect("style interns"); + let mut inline = InlineStyleTable::new(1); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: padded, + content: text_items(&mut inline, 2), + children: Vec::new(), + }], + FormattingNodeId(0), + FormattingTreeStyles { layout, inline }, + ) + .expect("tree builds"); + assert_eq!( + line_text_asked( + &tree, + LineText::Within { + top: 20.0, + bottom: 30.0 + } + ), + [( + FormattingNodeId(0), + LineText::Within { + top: 8.0, + bottom: 18.0 + } + )] + ); +} + +#[test] +fn a_flex_paragraph_that_centres_its_lines_holds_all_their_text() { + // Its lines move once they are laid out, so no range can say which of + // them a window reaches. + let mut layout = LayoutStyleTable::new(0); + let plain = layout + .intern(block_style(margin_px(0.0), margin_px(0.0))) + .expect("style interns"); + let mut centring = block_style(margin_px(0.0), margin_px(0.0)); + centring.display.inside = LayoutDisplayInside::Flex; + centring.align_items = AlignItems::Center; + let centring = layout.intern(centring).expect("style interns"); + let mut inline = InlineStyleTable::new(1); + let tree = FormattingTree::with_styles( + vec![ + FormattingNode { + style: centring, + content: text_items(&mut inline, 2), + children: Vec::new(), + }, + FormattingNode { + style: plain, + content: FormattingNodeContent::BlockContainer, + children: vec![FormattingNodeId(0)], + }, + ], + FormattingNodeId(1), + FormattingTreeStyles { layout, inline }, + ) + .expect("tree builds"); + assert_eq!( + line_text_asked( + &tree, + LineText::Within { + top: 0.0, + bottom: 5.0 + } + ), + [(FormattingNodeId(0), LineText::All)] + ); +} diff --git a/crates/rito-block/src/tests/margins.rs b/crates/rito-block/src/tests/margins.rs index ad89a56f0..5361b3a64 100644 --- a/crates/rito-block/src/tests/margins.rs +++ b/crates/rito-block/src/tests/margins.rs @@ -17,7 +17,8 @@ fn horizontal_padding_truncates_onto_the_layout_unit_grid() { }; style.padding.left = pad(12.16); style.padding.right = pad(1.216); - let hbox = resolve_horizontal_box(&style, 581.15625).expect("box resolves"); + let hbox = resolve_inline_box(&style, WritingDirection::HORIZONTAL_LTR, 581.15625) + .expect("box resolves"); assert!( (hbox.padding_left - 12.15625).abs() < 1e-9, "left {}", @@ -545,7 +546,11 @@ fn a_snug_last_paragraph_keeps_its_full_margin() { }, baseline: 16.0, trailing_whitespace: 0.0, - ruby_growth: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, text_runs: Vec::new(), children: Vec::new(), }) @@ -562,6 +567,7 @@ fn a_snug_last_paragraph_keeps_its_full_margin() { height: LINE * items.len() as f64, }, children: lines, + cuts: rito_fragment::BlockCuts::NONE, }), }, continuation: None, @@ -724,8 +730,9 @@ fn a_margin_meeting_an_unforced_break_is_truncated() { assert_eq!(pages.len(), 2); let first_children = box_children(&pages[0]); assert_eq!(first_children.len(), 1); - // Page 1 seals at the content edge, without the truncated margin. - assert!((pages[0].fragments.root.rect().height - 20.0).abs() < 1e-9); + // Page 1's container breaks before paragraph 2 and keeps the rest of + // the fragmentainer, the truncated margin's room included. + assert!((pages[0].fragments.root.rect().height - 25.0).abs() < 1e-9); let second_children = box_children(&pages[1]); assert_eq!(second_children.len(), 1); assert!((second_children[0].rect().y - 0.0).abs() < 1e-9); diff --git a/crates/rito-block/src/tests/pagination.rs b/crates/rito-block/src/tests/pagination.rs index 82455ac2f..a3e794c9d 100644 --- a/crates/rito-block/src/tests/pagination.rs +++ b/crates/rito-block/src/tests/pagination.rs @@ -717,9 +717,10 @@ fn nested_containers_stack_in_continuous_space() { fn breaks_inside_nested_containers_resume_through_the_ancestor_path() { let context = BlockFormattingContext::new(FixedLineInline); let tree = nested_tree(); - // 30px pages over 80px of lines: the first break lands inside the - // inner container (paragraph B's second line would overflow page 1). - let space = ConstraintSpace::fragmented(100.0, 30.0); + // 45px pages over 80px of lines: the first break lands inside the + // inner container, after paragraph B (paragraph C's second line + // would overflow page 1, and C keeps its two lines together). + let space = ConstraintSpace::fragmented(100.0, 45.0); let cancel = CancelFlag::new(); let first = context .layout(&tree, tree.root(), &space, None, &cancel) @@ -727,8 +728,8 @@ fn breaks_inside_nested_containers_resume_through_the_ancestor_path() { let token = first.continuation.clone().expect("break token"); assert_eq!( token.resume_path, - vec![FormattingNodeId(3), FormattingNodeId(1)], - "the resume path names the inner container, then paragraph B" + vec![FormattingNodeId(3), FormattingNodeId(2)], + "the resume path names the inner container, then paragraph C" ); let resumed_a = context @@ -1059,6 +1060,7 @@ fn leading_padding_breaks_alone_when_it_squeezes_a_full_page_line() { float_band: None, containing_block_size: None, line_text: LineText::All, + preceding_line: None, }; let first = context .layout(&tree, tree.root(), &space, None, &CancelFlag::new()) @@ -1069,12 +1071,21 @@ fn leading_padding_breaks_alone_when_it_squeezes_a_full_page_line() { let Some(Fragment::Box(paragraph)) = root.children.first() else { panic!("padding-only paragraph fragment exists"); }; + // The 2px leading padding is all it holds; breaking before its line, + // the box keeps the rest of the page, its block end cut. assert!( - paragraph.children.is_empty() && (paragraph.rect.height - 2.0).abs() < 0.01, - "first fragment holds only the 2px leading padding: h={} lines={}", + paragraph.children.is_empty() && (paragraph.rect.height - 10.0).abs() < 0.01, + "first fragment holds only the leading padding, to the page end: h={} lines={}", paragraph.rect.height, paragraph.children.len() ); + assert_eq!( + paragraph.cuts, + rito_fragment::BlockCuts { + start: false, + end: true + } + ); let token = first.continuation.clone().expect("line resumes next page"); let second = context .layout(&tree, tree.root(), &space, Some(&token), &CancelFlag::new()) @@ -1160,6 +1171,7 @@ fn leading_padding_breaks_even_when_the_line_overflows_a_whole_page() { float_band: None, containing_block_size: None, line_text: LineText::All, + preceding_line: None, }; let first = context .layout(&tree, tree.root(), &space, None, &CancelFlag::new()) @@ -1170,12 +1182,21 @@ fn leading_padding_breaks_even_when_the_line_overflows_a_whole_page() { let Some(Fragment::Box(paragraph)) = root.children.first() else { panic!("padding-only paragraph fragment exists"); }; + // The 2px leading padding is all it holds; breaking before its line, + // the box keeps the rest of the page, its block end cut. assert!( - paragraph.children.is_empty() && (paragraph.rect.height - 2.0).abs() < 0.01, - "first fragment holds only the 2px leading padding: h={} lines={}", + paragraph.children.is_empty() && (paragraph.rect.height - 8.0).abs() < 0.01, + "first fragment holds only the leading padding, to the page end: h={} lines={}", paragraph.rect.height, paragraph.children.len() ); + assert_eq!( + paragraph.cuts, + rito_fragment::BlockCuts { + start: false, + end: true + } + ); let token = first.continuation.clone().expect("line resumes next page"); let second = context .layout(&tree, tree.root(), &space, Some(&token), &CancelFlag::new()) @@ -1268,6 +1289,7 @@ fn deferred_trailing_padding_opens_the_next_page_and_preserves_the_sibling_margi float_band: None, containing_block_size: None, line_text: LineText::All, + preceding_line: None, }; let first = context .layout(&tree, tree.root(), &space, None, &CancelFlag::new()) diff --git a/crates/rito-block/src/tests/tables.rs b/crates/rito-block/src/tests/tables.rs index 2e04cae57..722068cca 100644 --- a/crates/rito-block/src/tests/tables.rs +++ b/crates/rito-block/src/tests/tables.rs @@ -187,11 +187,14 @@ fn a_page_tall_single_cell_table_fragments_between_its_paragraphs() { let tree = table_tree(&[&[&[1, 1, 1, 1, 1, 1]]]); let pages = paginate(&context, &tree, ConstraintSpace::fragmented(100.0, 25.0)); assert_eq!(pages.len(), 3); - for page in &pages { + for (index, page) in pages.iter().enumerate() { let table = table_fragment(page); + // A cell that breaks keeps the rest of its fragmentainer; the + // last fragment ends with its two paragraphs. + let expected = if index + 1 < pages.len() { 25.0 } else { 20.0 }; assert!( - (table.rect.height - 20.0).abs() < 1e-9, - "each table fragment holds two 10px paragraphs, got {}", + (table.rect.height - expected).abs() < 1e-9, + "table fragment {index}: expected {expected}, got {}", table.rect.height ); let Fragment::Box(row) = &table.children[0] else { diff --git a/crates/rito-block/src/tests/zero_height_boxes.rs b/crates/rito-block/src/tests/zero_height_boxes.rs index 965212298..a193e8868 100644 --- a/crates/rito-block/src/tests/zero_height_boxes.rs +++ b/crates/rito-block/src/tests/zero_height_boxes.rs @@ -156,9 +156,10 @@ fn an_empty_box_keeps_a_padded_wrappers_head_on_the_first_page() { ConstraintSpace::fragmented(100.0, 8.0), ); assert_eq!(pages.len(), 2); + // The head breaks before the plate and keeps the rest of the page. assert_eq!( heights(&pages[0]), - vec![0.0, 2.0], + vec![0.0, 8.0], "empty box, padding-only head" ); let second = box_children(&pages[1]); @@ -167,6 +168,24 @@ fn an_empty_box_keeps_a_padded_wrappers_head_on_the_first_page() { panic!("wrapper fragment is a box"); }; assert_eq!(wrapper.children.len(), 1, "the plate opens the second page"); + // The wrapper's edges at the break belong to neither page's border: + // the head's block end and the continuation's block start are cut. + let Fragment::Box(head) = &box_children(&pages[0])[1] else { + panic!("wrapper head is a box"); + }; + assert_eq!( + (head.cuts, wrapper.cuts), + ( + rito_fragment::BlockCuts { + start: false, + end: true + }, + rito_fragment::BlockCuts { + start: true, + end: false + } + ) + ); } #[test] diff --git a/crates/rito-core/examples/chapter_fragment_probe.rs b/crates/rito-core/examples/chapter_fragment_probe.rs index a33fa79ff..8cb3ecef1 100644 --- a/crates/rito-core/examples/chapter_fragment_probe.rs +++ b/crates/rito-core/examples/chapter_fragment_probe.rs @@ -52,6 +52,10 @@ struct ProbeRequest { /// chapter file. Defaults to the reader-filtered production flow. #[serde(default)] unfiltered_flow: bool, + /// The initial font size the chapter's styles resolve against, as a + /// reader's root font size setting sets it. Defaults to 16px. + #[serde(default)] + root_font_size_px: Option, } #[derive(Deserialize)] @@ -131,7 +135,7 @@ fn main() { spread: SpreadMode::Single, first_page_alone: true, spread_gap: 0.0, - root_font_size: 16.0, + root_font_size: request.root_font_size_px.unwrap_or(16.0), line_height_override: None, line_height_force: None, font_family_override: None, @@ -183,6 +187,7 @@ fn main() { float_band: None, containing_block_size: None, line_text: rito_fragment::LineText::All, + preceding_line: None, }); if let Some(page_space) = paginated { let mut lines = Vec::new(); @@ -283,7 +288,9 @@ fn collect_lines( InlineItem::Text { text, .. } => Some(text.as_str()), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => None, + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => None, }) .collect(); let mut start = u32::MAX; diff --git a/crates/rito-core/examples/paint_digest.rs b/crates/rito-core/examples/paint_digest.rs index ec657b07d..31562d17d 100644 --- a/crates/rito-core/examples/paint_digest.rs +++ b/crates/rito-core/examples/paint_digest.rs @@ -294,6 +294,34 @@ fn digest_book(path: &str, policy: RuntimePinnedFontPolicyInput) -> Result String { + let mut out = String::from("["); + for (index, font) in artifact.fonts.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + let _ = write!( + out, + "ReaderFontRef {{ family: {:?}, href: {:?}, style: {:?}, weight: {:?}, \ + shape_fingerprint: {:?}, byte_length: {:?} }}", + font.family, + font.href, + font.style, + font.weight, + font.shape_fingerprint, + font.byte_length, + ); + if !font.features.is_empty() { + let _ = write!(out, " features: {:?}", font.features); + } + } + out.push(']'); + out +} + fn row(step: &str, scope: &str, spread_index: u32, artifact: &ReaderArtifact) -> String { let mut hasher = Sha256::new(); if let Some(dump) = env::var_os("PAINT_DIGEST_DUMP") { @@ -311,9 +339,9 @@ fn row(step: &str, scope: &str, spread_index: u32, artifact: &ReaderArtifact) -> Err(error) => hasher.update(format!("unreadable: {error}")), } hasher.update(format!( - "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + "{:?}|{}|{:?}|{:?}|{:?}|{:?}", artifact.resources, - artifact.fonts, + fonts(artifact), artifact.local_page_indexes, artifact.book_page_index, artifact.book_page_count, @@ -323,10 +351,10 @@ fn row(step: &str, scope: &str, spread_index: u32, artifact: &ReaderArtifact) -> } hasher.update(&artifact.display_list.bytes); hasher.update(format!( - "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + "{:?}|{:?}|{}|{:?}|{:?}|{:?}|{:?}", artifact.pages, artifact.resources, - artifact.fonts, + fonts(artifact), artifact.local_page_indexes, artifact.book_page_index, artifact.book_page_count, diff --git a/crates/rito-core/src/epub.rs b/crates/rito-core/src/epub.rs index 9f763c763..327c0ed64 100644 --- a/crates/rito-core/src/epub.rs +++ b/crates/rito-core/src/epub.rs @@ -87,6 +87,21 @@ pub struct PackageDocument { pub spine: Vec, #[serde(default)] pub toc: Vec, + /// The direction the spine's pages progress in. + #[serde(default)] + pub page_progression: PageProgression, +} + +/// The spine's `page-progression-direction`: which way a reader moves from +/// one page to the next. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PageProgression { + /// Not declared: the reading system's default, left to right. + #[default] + Default, + LeftToRight, + RightToLeft, } impl PackageDocument { diff --git a/crates/rito-core/src/epub/fonts/sources.rs b/crates/rito-core/src/epub/fonts/sources.rs index aac74f328..df0bbb57c 100644 --- a/crates/rito-core/src/epub/fonts/sources.rs +++ b/crates/rito-core/src/epub/fonts/sources.rs @@ -220,6 +220,7 @@ mod tests { manifest: Vec::new(), spine: Vec::new(), toc: Vec::new(), + page_progression: Default::default(), }, stylesheets: vec![ LoadedTextResource { diff --git a/crates/rito-core/src/epub/layout_bridge/tests.rs b/crates/rito-core/src/epub/layout_bridge/tests.rs index bb3be9506..b0d2f4500 100644 --- a/crates/rito-core/src/epub/layout_bridge/tests.rs +++ b/crates/rito-core/src/epub/layout_bridge/tests.rs @@ -99,6 +99,7 @@ fn supported_document() -> LoadedEpubDocument { manifest: Vec::new(), spine: Vec::new(), toc: Vec::new(), + page_progression: Default::default(), }, stylesheets: vec![LoadedTextResource { href: "styles/main.css".to_owned(), diff --git a/crates/rito-core/src/epub/parser.rs b/crates/rito-core/src/epub/parser.rs index 1bc12f5d5..db84404c0 100644 --- a/crates/rito-core/src/epub/parser.rs +++ b/crates/rito-core/src/epub/parser.rs @@ -1,6 +1,9 @@ use roxmltree::{Document, Node}; -use super::{EpubError, EpubResult, ManifestItem, PackageDocument, PackageMetadata, SpineItem}; +use super::{ + EpubError, EpubResult, ManifestItem, PackageDocument, PackageMetadata, PageProgression, + SpineItem, +}; pub(super) fn parse_container(container_xml: &str) -> EpubResult { let document = parse_xml(container_xml, "container.xml")?; @@ -23,9 +26,22 @@ pub(super) fn parse_package_document(opf_xml: &str) -> EpubResult) -> PageProgression { + match document + .descendants() + .find(|node| has_tag(node, "spine")) + .and_then(|spine| spine.attribute("page-progression-direction")) + { + Some("rtl") => PageProgression::RightToLeft, + Some("ltr") => PageProgression::LeftToRight, + _ => PageProgression::Default, + } +} + // Keep the package structure strict, but match the reference reader's // real-world tolerance for missing Dublin Core scalar values. fn parse_metadata(document: &Document<'_>) -> PackageMetadata { @@ -126,7 +142,7 @@ fn split_properties(value: &str) -> Vec { #[cfg(test)] mod tests { - use super::{parse_container, parse_package_document}; + use super::{parse_container, parse_package_document, PageProgression}; #[test] fn parses_container_rootfile_path() { @@ -166,6 +182,32 @@ mod tests { assert_eq!(package.manifest[1].properties, ["nav", "scripted"]); assert_eq!(package.spine.len(), 2); assert!(!package.spine[1].linear); + assert_eq!(package.page_progression, PageProgression::Default); + } + + #[test] + fn reads_the_spines_page_progression_direction() { + for (attribute, expected) in [ + ( + r#" page-progression-direction="rtl""#, + PageProgression::RightToLeft, + ), + ( + r#" page-progression-direction="ltr""#, + PageProgression::LeftToRight, + ), + ( + r#" page-progression-direction="default""#, + PageProgression::Default, + ), + ("", PageProgression::Default), + ] { + let package = parse_package_document(&format!( + r#""# + )) + .expect("package"); + assert_eq!(package.page_progression, expected, "{attribute}"); + } } #[test] diff --git a/crates/rito-core/src/epub/prepared.rs b/crates/rito-core/src/epub/prepared.rs index c11041129..45eadd532 100644 --- a/crates/rito-core/src/epub/prepared.rs +++ b/crates/rito-core/src/epub/prepared.rs @@ -52,6 +52,10 @@ impl RawStylesheetSource { #[derive(Debug, Clone)] pub(crate) struct StylesheetSourceLedger { sources: Arc<[RawStylesheetSource]>, + /// Whether a chapter takes only the stylesheets it links, as a browser + /// lays out a publication opened from its archive; a document built + /// through the API without links takes every publication stylesheet. + links_only: bool, } impl StylesheetSourceLedger { @@ -66,12 +70,17 @@ impl StylesheetSourceLedger { .collect::>(); Self { sources: Arc::from(sources), + links_only: document.archive_source.is_some(), } } pub(crate) fn sources(&self) -> &[RawStylesheetSource] { &self.sources } + + pub(crate) fn links_only(&self) -> bool { + self.links_only + } } #[derive(Debug, Clone)] diff --git a/crates/rito-core/src/epub/prepared/tests.rs b/crates/rito-core/src/epub/prepared/tests.rs index 4e5f5d3f3..5f9b855e3 100644 --- a/crates/rito-core/src/epub/prepared/tests.rs +++ b/crates/rito-core/src/epub/prepared/tests.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use rito_style_contract::{ Clear, ComputedColor, Float, InlineFormattingStyle, LayoutFormattingStyle, LengthPercentage, - LineHeight, MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, TransformOperation, + LineHeight, MaximumSize, MinimumSize, Overflow, PageBreak, TransformOperation, }; use crate::{ @@ -196,16 +196,19 @@ fn height_float_and_overflow_project_into_the_typed_layout_style() { let resolved = resolve(&base.stylesheet_ledger, &chapter); let paragraph = resolved.layout_for_tag(&chapter, "p"); - let MinimumHeight::Length(min_height) = paragraph.min_height else { + let MinimumSize::Length(min_height) = paragraph.min_height else { panic!("min-height keeps its length: {:?}", paragraph.min_height); }; assert_eq!(min_height.get(), 12.0); - let MaximumHeight::Percentage(max_height) = paragraph.max_height else { + let MaximumSize::Value(max_height) = paragraph.max_height else { panic!( "max-height keeps its percentage: {:?}", paragraph.max_height ); }; + let LengthPercentage::Percentage(max_height) = max_height.value() else { + panic!("max-height keeps its percentage: {max_height:?}"); + }; assert_eq!(max_height.percent(), 100.0); assert_eq!(paragraph.float, Float::Right); assert_eq!(paragraph.overflow, Overflow::Hidden); @@ -651,6 +654,7 @@ fn document_with_stylesheet(href: &str, text: &str) -> LoadedEpubDocument { manifest: Vec::new(), spine: Vec::new(), toc: Vec::new(), + page_progression: Default::default(), }, stylesheets: vec![LoadedTextResource { href: href.to_owned(), diff --git a/crates/rito-core/src/fragment_bridge.rs b/crates/rito-core/src/fragment_bridge.rs index d527602c3..701137969 100644 --- a/crates/rito-core/src/fragment_bridge.rs +++ b/crates/rito-core/src/fragment_bridge.rs @@ -20,7 +20,7 @@ use rito_fragment::{ InlineItem, }; use rito_style_contract::{ - GeneratedBoxTable, InlineStyleTable, LayoutStyleId, LayoutStyleTable, StyleId, + GeneratedBoxTable, InlineStyleTable, LayoutStyleId, LayoutStyleTable, StyleId, WritingDirection, }; use std::collections::BTreeMap; @@ -103,6 +103,9 @@ pub struct ChapterFormattingTree { /// `ruby-align`, and how far over the base's baseline the annotation /// line sits. Filled by [`ChapterFormattingTree::measure_painted_runs`]. pub ruby_annotation_runs: BTreeMap<(u32, usize), rito_inline::MeasuredRuby>, + /// The emphasis mark of each inline style that takes one, measured for + /// painting. Filled by [`ChapterFormattingTree::measure_painted_runs`]. + pub emphasis_marks: BTreeMap, /// Each inline flow's text items joined as its text fragments address /// them, by node id: joined once with the tree, read for every line a /// page's query artifact walks. @@ -170,8 +173,9 @@ impl ListMarkerPaint { impl ChapterFormattingTree { /// Shapes the strings the painter places without layout — every /// outside marker in its item's style, every ruby annotation at its - /// own size — so the painter works from the engine's own advances - /// and cluster origins with no host measurement. + /// own size, every emphasis mark at its — so the painter works from + /// the engine's own advances and cluster origins with no host + /// measurement. pub fn measure_painted_runs( &mut self, context: &rito_inline::ParleyInlineContext, @@ -188,6 +192,7 @@ impl ChapterFormattingTree { marker.run = Some(context.measure_run(style, &marker.painted_text())); } self.ruby_annotation_runs.clear(); + self.emphasis_marks.clear(); for index in 0..self.tree.len() { let node_id = FormattingNodeId(index as u32); let FormattingNodeContent::InlineFlow { items } = &self.tree.node(node_id).content @@ -195,6 +200,17 @@ impl ChapterFormattingTree { continue; }; for (item_index, item) in items.iter().enumerate() { + if let InlineItem::Text { style: id, .. } = item { + if !self.emphasis_marks.contains_key(id) { + let style = styles + .inline + .style(*id) + .map_err(|error| EpubError::new(format!("text style: {error}")))?; + if let Some(mark) = context.measure_emphasis_mark(style) { + self.emphasis_marks.insert(*id, mark); + } + } + } let InlineItem::Text { text, style, @@ -331,7 +347,7 @@ pub fn build_chapter_formatting_tree_with_generated( ) -> EpubResult { let mut layout = layout.clone(); let anonymous_style = layout - .intern(anonymous_block_style()) + .intern(anonymous_block_style(WritingDirection::HORIZONTAL_LTR)) .map_err(|error| EpubError::new(format!("anonymous block style interns: {error}")))?; let mut inline = inline.clone(); let fallback_inline_style = inline @@ -415,7 +431,8 @@ pub fn build_chapter_formatting_tree_with_generated( degradations, .. } = builder; - fold_through_collapsing_margins(&mut formatting_nodes, root, &mut layout)?; + let root_leading_margin = + fold_through_collapsing_margins(&mut formatting_nodes, root, &mut layout)?; let mut tree = FormattingTree::with_styles( formatting_nodes, root, @@ -423,6 +440,7 @@ pub fn build_chapter_formatting_tree_with_generated( ) .map_err(EpubError::new)?; tree.set_strut_styles(strut_styles); + tree.set_root_leading_margin(root_leading_margin); let flow_texts = flow_texts(&tree); Ok(ChapterFormattingTree { tree, @@ -439,6 +457,7 @@ pub fn build_chapter_formatting_tree_with_generated( node_tags, list_markers, ruby_annotation_runs: BTreeMap::new(), + emphasis_marks: BTreeMap::new(), degradations, }) } @@ -448,6 +467,9 @@ struct TreeBuilder<'a> { inline: &'a mut InlineStyleTable, generated: &'a GeneratedBoxTable, image_dimensions: &'a BTreeMap, + /// The anonymous block style in the initial writing mode and direction, + /// which a node whose projected style is missing falls back to; see + /// `TreeBuilder::anonymous_style_in` for a wrapper's own. anonymous_style: LayoutStyleId, /// The style a node falls back to when the projection retained no /// inline entry for it (its own declarations were unrepresentable). @@ -479,7 +501,7 @@ struct TreeBuilder<'a> { /// checked once per chapter. checked_block_styles: std::collections::HashMap>, checked_box_paints: std::collections::HashMap, - checked_inline_styles: std::collections::HashMap<(u32, bool), Option>, + checked_inline_styles: std::collections::HashMap<(u32, builder::InlineScope), Option>, } /// One box-paint capability verdict: the paint (with its border widths) @@ -497,6 +519,9 @@ struct InlineCollector { /// The nearest enclosing `` destination while collecting. current_link: Option, pending_space: bool, + /// How many items preceded the pending space when it arose: the box + /// edges pushed since then come after it. + pending_space_at: usize, /// The style of a whitespace-only node awaiting content. `None` for a /// space produced by the previous text node itself (it belongs to /// that item); `Some` for an inter-element space, which must not @@ -514,7 +539,7 @@ struct InlineCollector { pub fn empty_chapter_formatting_tree() -> EpubResult { let mut layout = LayoutStyleTable::new(1); let style = layout - .intern(anonymous_block_style()) + .intern(anonymous_block_style(WritingDirection::HORIZONTAL_LTR)) .map_err(|error| EpubError::new(format!("anonymous block style interns: {error}")))?; let tree = FormattingTree::with_styles( vec![FormattingNode { @@ -543,6 +568,7 @@ pub fn empty_chapter_formatting_tree() -> EpubResult { node_tags: BTreeMap::new(), list_markers: BTreeMap::new(), ruby_annotation_runs: BTreeMap::new(), + emphasis_marks: BTreeMap::new(), flow_texts: BTreeMap::new(), degradations: vec!["chapter has no body source node; rendered empty".to_owned()], }) diff --git a/crates/rito-core/src/fragment_bridge/builder.rs b/crates/rito-core/src/fragment_bridge/builder.rs index f3c6781d8..9cc8b2a33 100644 --- a/crates/rito-core/src/fragment_bridge/builder.rs +++ b/crates/rito-core/src/fragment_bridge/builder.rs @@ -12,14 +12,15 @@ use rito_fragment::{FormattingNode, FormattingNodeContent, FormattingNodeId, InlineItem}; use rito_style_contract::{ GeneratedBox, JustifyContent, LayoutDisplayInside, LayoutDisplayOutside, LayoutStyleId, - LengthPercentage, NonNegativeLengthPercentage, PseudoElementKind, StyleId, + LengthPercentage, NonNegativeLengthPercentage, PseudoElementKind, StyleId, WritingDirection, }; use capability::box_decoration_violation; +pub(super) use capability::InlineScope; use generated::Generated; use super::box_paint::block_box_paint; -use super::styles::list_marker_text; +use super::styles::{anonymous_block_style, list_marker_text}; use super::{InlineCollector, ListMarkerPaint, NodePaint, TreeBuilder}; use crate::epub::{EpubError, EpubResult}; use crate::render::contract::ReaderColor; @@ -64,6 +65,27 @@ impl TreeBuilder<'_> { } } + /// The style of an anonymous block box inside a box whose lines lay + /// out in the inline style `context`: initial box properties, in that + /// box's writing mode and direction. + pub(super) fn anonymous_style_in(&mut self, context: StyleId) -> EpubResult { + let bidi = self + .inline + .style(context) + .map_err(|error| EpubError::new(format!("anonymous box context style: {error}")))? + .bidi; + let writing = WritingDirection { + mode: bidi.writing_mode, + direction: bidi.direction, + }; + if writing == WritingDirection::HORIZONTAL_LTR { + return Ok(self.anonymous_style); + } + self.layout + .intern(anonymous_block_style(writing)) + .map_err(|error| EpubError::new(format!("anonymous block style interns: {error}"))) + } + pub(super) fn inline_style_id(&mut self, source_index: usize, what: &str) -> StyleId { match self.inline.node_style_id(source_index) { Ok(id) => id, @@ -76,6 +98,20 @@ impl TreeBuilder<'_> { } } + /// Whether the element is styled `display: inline-block`: an atomic + /// inline on its parent's lines whatever its tag, its content a block + /// formatting context of its own. + pub(super) fn is_inline_block(&self, element: &ElementNode) -> bool { + element.anchor_ref.is_none() + && element_source_index(element) + .ok() + .and_then(|index| self.layout.style_for_node(index).ok()) + .is_some_and(|style| { + style.display.outside == LayoutDisplayOutside::Inline + && style.display.inside == LayoutDisplayInside::FlowRoot + }) + } + fn is_display_none(&mut self, source_index: usize, what: &str) -> bool { match self.layout.style_for_node(source_index) { Ok(style) => style.display.outside == LayoutDisplayOutside::None, @@ -133,6 +169,12 @@ impl TreeBuilder<'_> { while index < children.len() { let child = &children[index]; match child { + // A block element styled `display: inline-block` is an + // atomic inline on its parent's lines, not a block of its + // own. + DocumentNode::Block(element) if self.is_inline_block(element) => { + pending_inline.push(child); + } DocumentNode::Block(element) => { self.flush_inline_run( &mut pending_inline, @@ -278,9 +320,10 @@ impl TreeBuilder<'_> { if !inline_items_have_substance(&items) { return Ok(()); } + let style = self.anonymous_style_in(container_inline_style)?; let id = self.push_node( FormattingNode { - style: self.anonymous_style, + style, content: FormattingNodeContent::InlineFlow { items }, children: Vec::new(), }, @@ -384,10 +427,9 @@ impl TreeBuilder<'_> { Some(href) => Some(self.block_link.replace(href)), None => None, }; - let has_block_children = element - .children - .iter() - .any(|child| matches!(child, DocumentNode::Block(_))); + let has_block_children = element.children.iter().any( + |child| matches!(child, DocumentNode::Block(block) if !self.is_inline_block(block)), + ); let id = if has_block_children { let container_inline_style = self.inline_style_id(source_index, &element.tag); let children = self.build_children_with_generated( @@ -435,9 +477,10 @@ impl TreeBuilder<'_> { let mut children: Vec = before_node.into_iter().chain(floats).collect(); if inline_items_have_substance(&items) { + let style = self.anonymous_style_in(inline_style)?; let flow = self.push_node( FormattingNode { - style: self.anonymous_style, + style, content: FormattingNodeContent::InlineFlow { items }, children: Vec::new(), }, @@ -527,25 +570,33 @@ impl TreeBuilder<'_> { .layout .style(style) .map_err(|error| EpubError::new(format!("{what} style resolves: {error}")))?; - let widen = |side: &mut NonNegativeLengthPercentage, width: f64| -> EpubResult<()> { + // Each side reports the border width it absorbed: the layout style + // names exactly the share of `padding` that is border. + let widen = |side: &mut NonNegativeLengthPercentage, + width: f64| + -> EpubResult { + let zero = rito_style_contract::NonNegativeCssPx::ZERO; if width <= 0.0 { - return Ok(()); + return Ok(zero); } let LengthPercentage::Length(px) = side.value() else { // Percentage padding cannot absorb a pixel border; keep // the padding untouched (the edge still paints, content // sits closer to it than a browser would place it). - return Ok(()); + return Ok(zero); }; let total = rito_style_contract::CssPx::new(px.get() + width as f32) .map_err(|error| EpubError::new(format!("{what} border padding: {error:?}")))?; *side = NonNegativeLengthPercentage::new(LengthPercentage::Length(total)); - Ok(()) + rito_style_contract::NonNegativeCssPx::new(width as f32) + .map_err(|error| EpubError::new(format!("{what} border width: {error:?}"))) + }; + derived.border_widths = rito_style_contract::PhysicalSides { + top: widen(&mut derived.padding.top, widths[0])?, + right: widen(&mut derived.padding.right, widths[1])?, + bottom: widen(&mut derived.padding.bottom, widths[2])?, + left: widen(&mut derived.padding.left, widths[3])?, }; - widen(&mut derived.padding.top, widths[0])?; - widen(&mut derived.padding.right, widths[1])?; - widen(&mut derived.padding.bottom, widths[2])?; - widen(&mut derived.padding.left, widths[3])?; static BORDER_DEBUG: std::sync::OnceLock = std::sync::OnceLock::new(); // Forensic output a native probe switches on; read once per process. if *BORDER_DEBUG.get_or_init(|| std::env::var_os("RITO_BORDER_DEBUG").is_some()) { diff --git a/crates/rito-core/src/fragment_bridge/builder/capability.rs b/crates/rito-core/src/fragment_bridge/builder/capability.rs index 07a7e832f..3993de305 100644 --- a/crates/rito-core/src/fragment_bridge/builder/capability.rs +++ b/crates/rito-core/src/fragment_bridge/builder/capability.rs @@ -71,14 +71,15 @@ impl TreeBuilder<'_> { /// borders, vertical-align) do not inherit and only apply to actual /// inline boxes, so a text run — which borrows its nearest element's /// style — is checked against the text-level fields only, while inline - /// elements and images are checked in full. + /// elements and images are checked in full, an atomic inline's + /// `vertical-align` against what atoms implement. pub(super) fn require_inline_capabilities( &mut self, style: StyleId, - is_box: bool, + scope: InlineScope, what: &str, ) -> EpubResult<()> { - let key = (style.raw(), is_box); + let key = (style.raw(), scope); let verdict = match self.checked_inline_styles.get(&key) { Some(cached) => cached.clone(), None => { @@ -86,11 +87,14 @@ impl TreeBuilder<'_> { .inline .style(style) .map_err(|error| EpubError::new(format!("{what} style resolves: {error}")))?; - let verdict = if is_box { - inline_text_capability_violation(resolved) + let verdict = match scope { + InlineScope::Text => inline_text_capability_violation(resolved), + InlineScope::Box => inline_text_capability_violation(resolved) .or_else(|| inline_box_capability_violation(resolved)) - } else { - inline_text_capability_violation(resolved) + .or_else(|| inline_box_alignment_violation(resolved)), + InlineScope::Atom => inline_text_capability_violation(resolved) + .or_else(|| inline_box_capability_violation(resolved)) + .or_else(|| atom_alignment_violation(resolved)), }; self.checked_inline_styles.insert(key, verdict.clone()); verdict @@ -134,11 +138,29 @@ fn block_capability_violation(style: &LayoutFormattingStyle) -> Option { c::PreferredSize::Auto | c::PreferredSize::Value(_) => {} other => return Some(format!("block height {other:?}")), } - if style.min_height != c::MinimumHeight::Auto { - return Some(format!("block min-height {:?}", style.min_height)); + // A block's minimum and maximum block size bound it in the block + // context; its minimum inline size and the percentage block bounds + // (no definite basis in block flow) are not implemented. + let block_axis_is_height = matches!( + style.writing_mode, + rito_style_contract::WritingMode::HorizontalTopToBottom + ); + let (min_inline, min_block, max_block) = if block_axis_is_height { + (style.min_width, style.min_height, style.max_height) + } else { + (style.min_height, style.min_width, style.max_width) + }; + if min_inline != c::MinimumSize::Auto { + return Some(format!("block min inline size {min_inline:?}")); + } + if let c::MinimumSize::Percentage(_) = min_block { + return Some(format!("block min block size {min_block:?} (no basis)")); } - if style.max_height != c::MaximumHeight::None { - return Some(format!("block max-height {:?}", style.max_height)); + match max_block { + c::MaximumSize::None => {} + c::MaximumSize::Value(value) if matches!(value.value(), c::LengthPercentage::Length(_)) => { + } + other => return Some(format!("block max block size {other:?}")), } // list-style-type inherits everywhere but only paints on // display: list-item boxes, which the display gate above rejects; the @@ -327,6 +349,15 @@ fn inline_box_capability_violation( return Some(format!("inline percentage {name}")); } } + None +} + +/// The `vertical-align` an inline box implements: its baseline on its +/// parent's, raised or lowered by `super`, `sub` or nothing. +fn inline_box_alignment_violation( + style: &rito_style_contract::InlineFormattingStyle, +) -> Option { + use rito_style_contract as c; match style.fragment.baseline_shift { c::BaselineShift::Offset(offset) if length_percentage_is_zero(&offset) => {} c::BaselineShift::Super | c::BaselineShift::Sub => {} @@ -338,3 +369,30 @@ fn inline_box_capability_violation( } None } + +/// The `vertical-align` an atomic inline implements: an inline box's, and +/// `top` and `middle` besides. +fn atom_alignment_violation(style: &rito_style_contract::InlineFormattingStyle) -> Option { + use rito_style_contract as c; + match style.fragment.baseline_shift { + c::BaselineShift::Offset(offset) if length_percentage_is_zero(&offset) => {} + c::BaselineShift::Super | c::BaselineShift::Sub | c::BaselineShift::Top => {} + other => return Some(format!("vertical-align {other:?}")), + } + match style.fragment.alignment_baseline { + c::AlignmentBaseline::Baseline | c::AlignmentBaseline::Middle => {} + other => return Some(format!("alignment-baseline {other:?}")), + } + None +} + +/// What an inline style is checked as. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(in crate::fragment_bridge) enum InlineScope { + /// A text run borrowing its element's style. + Text, + /// An inline box. + Box, + /// An atomic inline: an image or an inline-block. + Atom, +} diff --git a/crates/rito-core/src/fragment_bridge/builder/floated.rs b/crates/rito-core/src/fragment_bridge/builder/floated.rs index ccfa62b27..63307d624 100644 --- a/crates/rito-core/src/fragment_bridge/builder/floated.rs +++ b/crates/rito-core/src/fragment_bridge/builder/floated.rs @@ -168,8 +168,9 @@ fn float_box_style(image: &LayoutFormattingStyle) -> LayoutFormattingStyle { }, height: c::PreferredSize::Auto, max_width: c::MaximumSize::None, - min_height: c::MinimumHeight::Auto, - max_height: c::MaximumHeight::None, + min_height: c::MinimumSize::Auto, + min_width: c::MinimumSize::Auto, + max_height: c::MaximumSize::None, position: c::Position::Static, ..*image } diff --git a/crates/rito-core/src/fragment_bridge/builder/generated.rs b/crates/rito-core/src/fragment_bridge/builder/generated.rs index 569d9671b..545e47c04 100644 --- a/crates/rito-core/src/fragment_bridge/builder/generated.rs +++ b/crates/rito-core/src/fragment_bridge/builder/generated.rs @@ -10,6 +10,7 @@ use rito_style_contract::{ Float, GeneratedBox, GeneratedContentItem, LayoutDisplayOutside, Position, PseudoElementKind, }; +use super::InlineScope; use crate::epub::{EpubError, EpubResult}; use crate::fragment_bridge::{InlineCollector, TreeBuilder}; @@ -81,7 +82,7 @@ impl TreeBuilder<'_> { source_index: usize, collector: &mut InlineCollector, ) -> EpubResult<()> { - self.require_inline_capabilities(generated.inline, true, "generated box")?; + self.require_inline_capabilities(generated.inline, InlineScope::Box, "generated box")?; let text = self.generated_text(generated); if text.is_empty() { collector.push_empty_box(generated.inline, baseline_shift_px, source_index); diff --git a/crates/rito-core/src/fragment_bridge/builder/inline.rs b/crates/rito-core/src/fragment_bridge/builder/inline.rs index ed7e4abff..135f6572b 100644 --- a/crates/rito-core/src/fragment_bridge/builder/inline.rs +++ b/crates/rito-core/src/fragment_bridge/builder/inline.rs @@ -8,13 +8,13 @@ //! its `vertical-align`, flatten ruby parts to plain text, and collapse an //! annotation's white space. -use rito_fragment::{FormattingNode, FormattingNodeContent, InlineItem}; +use rito_fragment::{AtomAlign, FormattingNode, FormattingNodeContent, InlineItem}; use rito_style_contract::{ LayoutDisplayInside, LayoutDisplayOutside, LayoutStyleId, PseudoElementKind, StyleId, WhiteSpaceCollapse, }; -use super::{element_source_index, inline_items_have_substance}; +use super::{element_source_index, inline_items_have_substance, InlineScope}; use crate::epub::{EpubError, EpubResult}; use crate::fragment_bridge::{InlineCollector, TreeBuilder}; use crate::xhtml::{DocumentNode, ElementNode}; @@ -43,7 +43,7 @@ impl TreeBuilder<'_> { // (measured: a calibre story's four-space paragraph // indents, kept by Blink under `white-space: pre-wrap`). if !self.white_space_collapse(inherited)? { - self.require_inline_capabilities(inherited, false, "text run")?; + self.require_inline_capabilities(inherited, InlineScope::Text, "text run")?; collector.push_text( text.source_text.as_deref().unwrap_or(&text.content), inherited, @@ -65,7 +65,7 @@ impl TreeBuilder<'_> { collector.push_collapsible_whitespace(inherited); return Ok(()); } - self.require_inline_capabilities(inherited, false, "text run")?; + self.require_inline_capabilities(inherited, InlineScope::Text, "text run")?; let collapse = self.white_space_collapse(inherited)?; collector.push_text( &text.content, @@ -96,22 +96,38 @@ impl TreeBuilder<'_> { return Ok(()); } let style = self.inline_style_id(source_index, &element.tag); - self.require_inline_capabilities(style, true, &element.tag)?; + // A `display: inline-block` span is an ATOMIC inline: its + // content becomes a hidden mini-paragraph node the inline + // engine lays out recursively at shrink-to-fit width + // (CSS 2.1 §10.3.5), riding the host line as one box whose + // baseline is its last line's (§10.8.1). Content that is + // not inline-only falls back to plain flattening. + let layout_style_id = self.layout_style_id(source_index, &element.tag); + let is_inline_block = self + .layout + .style(layout_style_id) + .map(|resolved| { + resolved.display.outside == LayoutDisplayOutside::Inline + && resolved.display.inside == LayoutDisplayInside::FlowRoot + }) + .unwrap_or(false); + let scope = if is_inline_block { + InlineScope::Atom + } else { + InlineScope::Box + }; + self.require_inline_capabilities(style, scope, &element.tag)?; let style = self.propagate_text_decorations(style, inherited)?; + let shift = + ancestor_shift_px + self.baseline_shift(style, inherited, &element.tag)?; let resolved = self .inline .style(style) .map_err(|error| EpubError::new(format!("{} style: {error}", element.tag)))?; - let parent_font_size = self - .inline - .style(inherited) - .map(|parent| f64::from(parent.font.size.get())) - .map_err(|error| { - EpubError::new(format!("{} parent style: {error}", element.tag)) - })?; - let parent_style = self.inline.style(inherited).ok(); - let shift = ancestor_shift_px - + resolved_baseline_shift_with_parent(resolved, parent_font_size, parent_style); + // Blink's open and close tag items: the box's start and end + // sizes take their width on the lines its content lands on. + let opens = !resolved.inline_edge(false).is_empty(); + let closes = !resolved.inline_edge(true).is_empty(); // An scopes its destination over everything it // contains; nested links (invalid HTML) keep the inner. let link = (element.tag == "a") @@ -126,38 +142,28 @@ impl TreeBuilder<'_> { Some(href) => Some(collector.current_link.replace(href)), None => None, }; - // A `display: inline-block` span is an ATOMIC inline: its - // content becomes a hidden mini-paragraph node the inline - // engine lays out recursively at shrink-to-fit width - // (CSS 2.1 §10.3.5), riding the host line as one box whose - // baseline is its last line's (§10.8.1). Content that is - // not inline-only falls back to plain flattening. - let layout_style_id = self.layout_style_id(source_index, &element.tag); - let is_inline_block = self - .layout - .style(layout_style_id) - .map(|resolved| { - resolved.display.outside == LayoutDisplayOutside::Inline - && resolved.display.inside == LayoutDisplayInside::FlowRoot - }) - .unwrap_or(false); - if is_inline_block - && self.collect_inline_block( - element, - source_index, + if is_inline_block { + let align = self.atom_align(style, inherited, &element.tag)?; + let standing = AtomStanding { style, - layout_style_id, - shift, - collector, - )? - { - if let Some(saved) = saved_link { - collector.current_link = saved; + layout_style: layout_style_id, + baseline_shift_px: shift, + align, + }; + if self.collect_inline_block(element, source_index, standing, collector)? { + if let Some(saved) = saved_link { + collector.current_link = saved; + } + return Ok(()); } - return Ok(()); + // Flattened: its alignment is an inline box's now. + self.require_inline_capabilities(style, InlineScope::Box, &element.tag)?; } let before = self.generated_inline(source_index, PseudoElementKind::Before); let after = self.generated_inline(source_index, PseudoElementKind::After); + if opens { + collector.push_box_edge(style, false, source_index); + } if element.children.is_empty() && before.is_none() && after.is_none() { // A childless inline box still opens on the line: its // font's leaded envelope around its shifted baseline @@ -176,6 +182,9 @@ impl TreeBuilder<'_> { if let Some(generated) = &after { self.collect_generated(generated, shift, source_index, collector)?; } + if closes { + collector.push_box_edge(style, true, source_index); + } if let Some(saved) = saved_link { collector.current_link = saved; } @@ -184,6 +193,26 @@ impl TreeBuilder<'_> { DocumentNode::Image(image) => { self.collect_image(image, inherited, ancestor_shift_px, collector, false) } + DocumentNode::Block(element) if self.is_inline_block(element) => { + let source_index = element_source_index(element)?; + if self.is_display_none(source_index, &element.tag) { + return Ok(()); + } + let style = self.inline_style_id(source_index, &element.tag); + self.require_inline_capabilities(style, InlineScope::Atom, &element.tag)?; + let style = self.propagate_text_decorations(style, inherited)?; + let shift = + ancestor_shift_px + self.baseline_shift(style, inherited, &element.tag)?; + let layout_style = self.layout_style_id(source_index, &element.tag); + let standing = AtomStanding { + style, + layout_style, + baseline_shift_px: shift, + align: self.atom_align(style, inherited, &element.tag)?, + }; + self.collect_inline_block(element, source_index, standing, collector)?; + Ok(()) + } DocumentNode::Block(element) => Err(EpubError::new(format!( "block-level <{}> inside an inline run; anonymous box grouping missed it", element.tag @@ -191,22 +220,91 @@ impl TreeBuilder<'_> { } } - /// Builds one `display: inline-block` span into an atomic inline: a - /// hidden mini-paragraph node holding its (inline-only) content, and - /// an `InlineItem::InlineBlock` referencing it. Returns `false` — - /// pushing nothing — when the content is not representable as a flow - /// (a block child inside), so the caller flattens instead. A failed + /// The baseline shift an inline box styled `style` takes from its + /// `vertical-align` within its parent styled `inherited`, CSS px. + fn baseline_shift(&mut self, style: StyleId, inherited: StyleId, tag: &str) -> EpubResult { + let resolved = self + .inline + .style(style) + .map_err(|error| EpubError::new(format!("{tag} style: {error}")))?; + let parent_style = self + .inline + .style(inherited) + .map_err(|error| EpubError::new(format!("{tag} parent style: {error}")))?; + Ok(resolved_baseline_shift_with_parent( + resolved, + f64::from(parent_style.font.size.get()), + Some(parent_style), + )) + } + + /// How the atomic inline styled `style` aligns on its line, from its own + /// `vertical-align`, in its parent box styled `parent` (whose primary + /// font's x-height `middle` centres it on). + pub(super) fn atom_align( + &self, + style: StyleId, + parent: StyleId, + tag: &str, + ) -> EpubResult { + let resolved = self + .inline + .style(style) + .map_err(|error| EpubError::new(format!("{tag} style: {error}")))?; + Ok( + match ( + resolved.fragment.alignment_baseline, + resolved.fragment.baseline_shift, + ) { + (rito_style_contract::AlignmentBaseline::Middle, _) => AtomAlign::Middle { parent }, + (_, rito_style_contract::BaselineShift::Top) => AtomAlign::Top, + _ => AtomAlign::Baseline, + }, + ) + } + + /// Builds one `display: inline-block` element into an atomic inline: a + /// hidden node holding its content — a mini paragraph when the content + /// is inline-level, else the block container it would be as a block — + /// and an `InlineItem::InlineBlock` referencing it. An empty one is an + /// atom too: its own size and baseline still stand on the line. + /// Returns `false` — pushing nothing — when inline content is not + /// representable as a flow, so the caller flattens instead. A failed /// attempt may leave orphan nodes in the arena; they are unreachable /// and ids are never reused, so they cost only their bytes. fn collect_inline_block( &mut self, element: &ElementNode, source_index: usize, - style: StyleId, - layout_style: LayoutStyleId, - baseline_shift_px: f64, + standing: AtomStanding, collector: &mut InlineCollector, ) -> EpubResult { + // An inline-block hanging from the line top is not laid out yet: + // it keeps its baseline. + let standing = if standing.align == AtomAlign::Top { + self.degrade(format!( + "<{}> inline-block vertical-align top laid out on the baseline", + element.tag + )); + AtomStanding { + align: AtomAlign::Baseline, + ..standing + } + } else { + standing + }; + let AtomStanding { + style, + layout_style, + .. + } = standing; + let holds_blocks = element.children.iter().any( + |child| matches!(child, DocumentNode::Block(block) if !self.is_inline_block(block)), + ); + if holds_blocks { + self.collect_block_container_atom(element, source_index, standing, collector)?; + return Ok(true); + } let mut sub = self.inline_collector(); sub.current_link = collector.current_link.clone(); if let Some(generated) = self.generated_inline(source_index, PseudoElementKind::Before) { @@ -226,9 +324,10 @@ impl TreeBuilder<'_> { } let (items, sources) = sub.finish(); if !inline_items_have_substance(&items) { - // An empty inline-block has no ink and no last-line baseline; - // the flattening path yields the same nothing. - return Ok(false); + // An empty one has no line box: an empty block container, as + // tall as its own height, padding and borders make it. + self.collect_block_container_atom(element, source_index, standing, collector)?; + return Ok(true); } let node = self.push_node( FormattingNode { @@ -241,12 +340,7 @@ impl TreeBuilder<'_> { self.strut_styles.insert(node.0, style); self.flow_item_sources.insert(node.0, sources); collector.push_image( - InlineItem::InlineBlock { - node, - style, - layout_style, - baseline_shift_px, - }, + standing.item(node), source_index, element.source_ref.node_path.clone(), "", @@ -254,6 +348,29 @@ impl TreeBuilder<'_> { Ok(true) } + /// Pushes an inline block whose content is not one paragraph — block + /// children, or nothing — as the block container it would be as a + /// block (Blink lays an inline-block out as a block flow root), laid + /// out at the atom's size by the block context. + fn collect_block_container_atom( + &mut self, + element: &ElementNode, + source_index: usize, + standing: AtomStanding, + collector: &mut InlineCollector, + ) -> EpubResult<()> { + let Some(node) = self.build_block(element)? else { + return Ok(()); + }; + collector.push_image( + standing.item(node), + source_index, + element.source_ref.node_path.clone(), + "", + ); + Ok(()) + } + /// Collects one `` element as mono-ruby pairs: each `` closes /// the base text accumulated before it, producing one text item whose /// annotation paints above that base segment's laid-out extent at half @@ -273,9 +390,12 @@ impl TreeBuilder<'_> { return Ok(()); } let style = self.inline_style_id(source_index, "ruby"); - self.require_inline_capabilities(style, true, "ruby")?; + self.require_inline_capabilities(style, InlineScope::Box, "ruby")?; let collapse = self.white_space_collapse(style)?; let mut pending_base = String::new(); + // Whether a pair of this element already went out: the later ones + // share its box. + let mut paired = false; // The text nodes the pending base was read from, in order. let mut base_nodes: Vec<&crate::xhtml::TextNode> = Vec::new(); for child in &element.children { @@ -333,6 +453,7 @@ impl TreeBuilder<'_> { text, size_ratio, align: ruby_align, + continues_ruby: std::mem::replace(&mut paired, true), }), base_index, base_path, @@ -404,6 +525,30 @@ impl TreeBuilder<'_> { } } +/// How an inline-block atom stands on its host line: its own inline and +/// layout styles, the baseline shift the boxes around it give it, and its +/// own `vertical-align`. +#[derive(Clone, Copy)] +struct AtomStanding { + style: StyleId, + layout_style: LayoutStyleId, + baseline_shift_px: f64, + align: AtomAlign, +} + +impl AtomStanding { + /// The atom item for the content node `node`. + fn item(self, node: rito_fragment::FormattingNodeId) -> InlineItem { + InlineItem::InlineBlock { + node, + style: self.style, + layout_style: self.layout_style, + baseline_shift_px: self.baseline_shift_px, + align: self.align, + } + } +} + /// The baseline shift one inline box asks for, CSS px; positive raises /// content above the baseline. `super`/`sub` shift by the PARENT's font /// (CSS 2.1 §10.8.1: "the proper position for superscripts of the diff --git a/crates/rito-core/src/fragment_bridge/builder/replaced.rs b/crates/rito-core/src/fragment_bridge/builder/replaced.rs index 96fc856b4..66b7c029e 100644 --- a/crates/rito-core/src/fragment_bridge/builder/replaced.rs +++ b/crates/rito-core/src/fragment_bridge/builder/replaced.rs @@ -7,8 +7,8 @@ use rito_fragment::{FormattingNode, FormattingNodeContent, FormattingNodeId, InlineItem}; use rito_style_contract::StyleId; -use super::element_source_index; use super::inline::resolved_baseline_shift; +use super::{element_source_index, InlineScope}; use crate::epub::{EpubError, EpubResult}; use crate::fragment_bridge::{InlineCollector, NodePaint, TreeBuilder}; use crate::render::contract::ReaderBorderStyle; @@ -156,12 +156,15 @@ impl TreeBuilder<'_> { // centered row of art + missing plate shifts by half the // placeholder — the b69 finale page's 19px displacement). // An empty alt collapses in Chromium (0×0); the 1×1 here - // is the closest the atom pipeline represents. + // is the closest the atom pipeline represents. An image + // with no alt at all keeps the bare icon (measured: a + // missing `` without alt in a 20px line grows the + // line one pixel, as the 16px icon on the baseline does). self.degrade(format!( "image dimensions unavailable, placeholder rendered: {}", image.src )); - if image.alt.is_empty() { + if image.alt.as_deref() == Some("") { (1, 1) } else { (16, 16) @@ -192,7 +195,7 @@ impl TreeBuilder<'_> { (style, layout_style) }; self.require_image_capabilities(layout_style)?; - self.require_inline_capabilities(style, true, "image")?; + self.require_inline_capabilities(style, InlineScope::Atom, "image")?; let resolved = self .inline .style(style) @@ -220,10 +223,7 @@ impl TreeBuilder<'_> { // stepping OUT of whatever baseline-shift chain wraps it (the // zhangyue footnote badge sits inside yet hugs the line // top in Blink; the sup's strut still raises the envelope). - let align_top = matches!( - resolved.fragment.baseline_shift, - rito_style_contract::BaselineShift::Top - ); + let align = self.atom_align(style, inherited, "image")?; let baseline_shift_px = ancestor_shift_px + resolved_baseline_shift( resolved, @@ -265,20 +265,21 @@ impl TreeBuilder<'_> { layout_style, fit_contain: image.svg_contain, viewport: image.svg_viewport, - align_top, + align, baseline_shift_px, object_fit, }, source_index, image.source_ref.node_path.clone(), - &image.alt, + image.alt.as_deref().unwrap_or_default(), ); - if dimensions.is_none() && !image.alt.is_empty() { + let alt_text = image.alt.as_deref().filter(|alt| !alt.is_empty()); + if let (None, Some(alt)) = (dimensions, alt_text) { // The placeholder's alt text follows the icon inline, in the // image element's own style — exactly the run Chromium lays // out for a broken image. collector.push_text( - &image.alt, + alt, style, baseline_shift_px, true, diff --git a/crates/rito-core/src/fragment_bridge/inline_collector.rs b/crates/rito-core/src/fragment_bridge/inline_collector.rs index 93dff84ce..6f5382927 100644 --- a/crates/rito-core/src/fragment_bridge/inline_collector.rs +++ b/crates/rito-core/src/fragment_bridge/inline_collector.rs @@ -13,11 +13,127 @@ impl InlineCollector { /// Records a run of collapsible white space with no content of its own. pub(super) fn push_collapsible_whitespace(&mut self, style: StyleId) { if self.has_content { - self.pending_space = true; + self.mark_pending_space(); self.pending_space_style = Some(style); } } + /// Marks a collapsed space as pending where the flow now stands: the + /// first space of a sequence keeps its place, ahead of the box edges + /// that come after it. + fn mark_pending_space(&mut self) { + if !self.pending_space { + self.pending_space_at = self.items.len(); + } + self.pending_space = true; + } + + /// The last item that is content (not a box edge) among the first + /// `end` items. + fn content_before(&self, end: usize) -> Option { + self.items[..end.min(self.items.len())] + .iter() + .rposition(|item| { + !matches!( + item, + InlineItem::OpenTag { .. } | InlineItem::CloseTag { .. } + ) + }) + } + + /// The plain text run a pending space follows, when the content before + /// it is one: the space settles at its end. + fn pending_space_owner(&mut self) -> Option<&mut String> { + let owner = self.content_before(self.pending_space_at)?; + match &mut self.items[owner] { + InlineItem::Text { + text, + ruby_annotation: None, + .. + } => Some(text), + _ => None, + } + } + + /// Puts the pending space where it arose, ahead of the box edges met + /// since: at the end of the text run it follows, or as a run of its own + /// when it carries a style other than that run's or follows a ruby base. + fn settle_pending_space(&mut self) { + let owner = self + .content_before(self.pending_space_at) + .map(|index| &self.items[index]); + let after_ruby = matches!( + owner, + Some(InlineItem::Text { + ruby_annotation: Some(_), + .. + }) + ); + let standalone = after_ruby + || match (self.pending_space_style, owner) { + ( + Some(space_style), + Some(InlineItem::Text { + style: last_style, .. + }), + ) => *last_style != space_style, + (Some(_), _) => true, + (None, _) => false, + }; + if !standalone { + if let Some(owner) = self.pending_space_owner() { + owner.push(' '); + } + return; + } + let space_style = self.pending_space_style.or(match owner { + Some(InlineItem::Text { style, .. }) => Some(*style), + _ => None, + }); + let Some(space_style) = space_style else { + return; + }; + let at = self.pending_space_at.min(self.items.len()); + self.items.insert( + at, + InlineItem::Text { + text: " ".to_owned(), + style: space_style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }, + ); + self.sources.insert( + at, + FlowItemSource { + source_index: None, + source_path: None, + href: self.current_link.clone(), + image_alt: None, + segments: Vec::new(), + }, + ); + } + + /// Appends the start (`closes` false) or end edge of an inline box + /// whose size there is not zero (Blink's open and close tag items). A + /// box edge holds no content: a space pending before it stays ahead of + /// it, and it does not end a line's leading white space. + pub(super) fn push_box_edge(&mut self, style: StyleId, closes: bool, source_index: usize) { + self.sources.push(FlowItemSource { + source_index: Some(source_index), + source_path: None, + href: self.current_link.clone(), + image_alt: None, + segments: Vec::new(), + }); + self.items.push(if closes { + InlineItem::CloseTag { style } + } else { + InlineItem::OpenTag { style } + }); + } + /// Appends one text node's content. A collapsed space belongs to the /// run that produced it (the CSS "first space of the sequence wins" /// rule), so a space pending from earlier nodes lands at the end of the @@ -52,7 +168,12 @@ impl InlineCollector { let utf16 = |value: &str| value.encode_utf16().count() as u32; let mut verbatim = String::with_capacity(text.len() + 1); if self.pending_space { - verbatim.push(' '); + if self.pending_space_at < self.items.len() { + // Box edges came after the space: it settles before them. + self.settle_pending_space(); + } else { + verbatim.push(' '); + } self.pending_space = false; self.pending_space_style = None; } @@ -84,7 +205,7 @@ impl InlineCollector { // whose source indents the text after `
` sat 0.25em right of // Blink's). if matches!( - self.items.last(), + self.content_before(self.items.len()).map(|index| &self.items[index]), Some(InlineItem::Text { text, .. }) if text.ends_with('\n') ) { self.pending_space = false; @@ -103,54 +224,7 @@ impl InlineCollector { // exactly the base's extent), and silently dropping it erased // the base's own trailing collapsed space (b11: 原初之火 // followed by ! sat 0.25em left of the browser's line). - let after_ruby = matches!( - self.items.last(), - Some(InlineItem::Text { - ruby_annotation: Some(_), - .. - }) - ); - let standalone = after_ruby - || match (self.pending_space_style, self.items.last()) { - ( - Some(space_style), - Some(InlineItem::Text { - style: last_style, .. - }), - ) => *last_style != space_style, - (Some(_), _) => true, - (None, _) => false, - }; - if standalone { - let space_style = self - .pending_space_style - .or_else(|| match self.items.last() { - Some(InlineItem::Text { style, .. }) => Some(*style), - _ => None, - }); - if let Some(space_style) = space_style { - self.items.push(InlineItem::Text { - text: " ".to_owned(), - style: space_style, - baseline_shift_px: 0.0, - ruby_annotation: None, - }); - self.sources.push(FlowItemSource { - source_index: None, - source_path: None, - href: self.current_link.clone(), - image_alt: None, - segments: Vec::new(), - }); - } - } else if let Some(InlineItem::Text { - text: last, - ruby_annotation: None, - .. - }) = self.items.last_mut() - { - last.push(' '); - } + self.settle_pending_space(); self.pending_space = false; self.pending_space_style = None; rest = rest.trim_start_matches(is_space); @@ -231,7 +305,7 @@ impl InlineCollector { if trailing_space && self.has_content { // This node ends in white space; it lands here if any content // follows, and collapses away at the end of the flow. - self.pending_space = true; + self.mark_pending_space(); self.pending_space_style = None; } } @@ -349,13 +423,8 @@ impl InlineCollector { alt: &str, ) { if self.pending_space { - if let Some(InlineItem::Text { - text: last, - ruby_annotation: None, - .. - }) = self.items.last_mut() - { - last.push(' '); + if let Some(owner) = self.pending_space_owner() { + owner.push(' '); } self.pending_space = false; } @@ -384,13 +453,8 @@ impl InlineCollector { source_index: usize, ) { if self.pending_space { - if let Some(InlineItem::Text { - text: last, - ruby_annotation: None, - .. - }) = self.items.last_mut() - { - last.push(' '); + if let Some(owner) = self.pending_space_owner() { + owner.push(' '); self.pending_space = false; } } diff --git a/crates/rito-core/src/fragment_bridge/margins.rs b/crates/rito-core/src/fragment_bridge/margins.rs index 111d68741..856436b09 100644 --- a/crates/rito-core/src/fragment_bridge/margins.rs +++ b/crates/rito-core/src/fragment_bridge/margins.rs @@ -3,11 +3,18 @@ //! bottom-up (padding, overflow, height, flow roots, leading or trailing //! floats and clearing spacers stop the escape), and the chapter body's //! own margins become body padding so the flow offsets on the first page. +//! +//! Margins collapse along the block axis: "top" and "bottom" here are the +//! block-start and block-end edges of the container's flow, the right and +//! left sides of a vertical-rl one. A box whose writing mode differs from +//! its parent's starts a formatting context of its own, which no margin +//! escapes. use rito_fragment::{FormattingNode, FormattingNodeContent, FormattingNodeId}; use rito_style_contract::{ - Clear, Float, LayoutStyleTable, LengthPercentage, LengthPercentageOrAuto, MinimumHeight, - NonNegativeLengthPercentage, Overflow, PreferredSize, + Clear, Float, LayoutFormattingStyle, LayoutStyleTable, LengthPercentage, + LengthPercentageOrAuto, MinimumSize, NonNegativeLengthPercentage, Overflow, PreferredSize, + WritingDirection, WritingMode, }; use crate::epub::{EpubError, EpubResult}; @@ -32,7 +39,7 @@ pub(super) fn fold_through_collapsing_margins( nodes: &mut [FormattingNode], root: FormattingNodeId, layout: &mut LayoutStyleTable, -) -> EpubResult<()> { +) -> EpubResult { fn resolved_px(value: LengthPercentageOrAuto) -> Option { match value { LengthPercentageOrAuto::Value(LengthPercentage::Length(px)) => { @@ -56,15 +63,22 @@ pub(super) fn fold_through_collapsing_margins( matches!(value.value(), LengthPercentage::Length(px) if px.get() == 0.0) || matches!(value.value(), LengthPercentage::Percentage(pct) if pct.ratio() == 0.0) } - /// CSS 2 §8.3.1 pairwise join: positives take the max, negatives the - /// most negative, mixed signs sum. - fn join(a: f64, b: f64) -> f64 { - a.max(0.0).max(b.max(0.0)) + a.min(0.0).min(b.min(0.0)) + /// The block-start and block-end margins of a box in a flow laid out + /// in `writing`. + fn block_margins( + style: &LayoutFormattingStyle, + writing: WritingDirection, + ) -> (LengthPercentageOrAuto, LengthPercentageOrAuto) { + let margin = style.margin_in(writing); + (margin.block_start, margin.block_end) } + /// Sets the block-start (`top`) and block-end (`bottom`) margins of a + /// box in a flow laid out in `writing`. fn set_margin( layout: &mut LayoutStyleTable, nodes: &mut [FormattingNode], node: FormattingNodeId, + writing: WritingDirection, top: Option, bottom: Option, ) -> EpubResult<()> { @@ -77,12 +91,14 @@ pub(super) fn fold_through_collapsing_margins( .map_err(|error| EpubError::new(format!("folded margin is finite: {error}")))?, ))) }; + let mut margin = style.margin_in(writing); if let Some(px) = top { - style.margin.top = as_length(px)?; + margin.block_start = as_length(px)?; } if let Some(px) = bottom { - style.margin.bottom = as_length(px)?; + margin.block_end = as_length(px)?; } + style.margin = margin.to_physical(writing); nodes[node.0 as usize].style = layout .intern(style) .map_err(|error| EpubError::new(format!("folded style interns: {error}")))?; @@ -93,23 +109,29 @@ pub(super) fn fold_through_collapsing_margins( node: FormattingNodeId, layout: &mut LayoutStyleTable, is_root: bool, + parent_mode: WritingMode, + sets: &mut FoldedSets, ) -> EpubResult<()> { + let style = *layout + .style(nodes[node.0 as usize].style) + .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))?; let children = nodes[node.0 as usize].children.clone(); for child in &children { if matches!( nodes[child.0 as usize].content, FormattingNodeContent::BlockContainer ) { - fold(nodes, *child, layout, false)?; + fold(nodes, *child, layout, false, style.writing_mode, sets)?; } } if children.is_empty() { return Ok(()); } - let style = *layout - .style(nodes[node.0 as usize].style) - .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))?; - if style.overflow != Overflow::Visible { + // The box's flow: its children's margins and its own lie on the + // block edges of these lines (a box laid out in another writing + // mode than its parent's contains its children's margins below). + let writing = style.writing_direction(); + if style.overflow != Overflow::Visible || style.writing_mode != parent_mode { return Ok(()); } // A flow root seals its margins: a FLOAT (or explicit flow-root) @@ -160,7 +182,7 @@ pub(super) fn fold_through_collapsing_margins( .unwrap_or(false) }) .is_some_and(|id| !in_flow(nodes, layout, id)); - if zero_padding(style.padding.top) && !float_leads { + if zero_padding(style.flow_padding().block_start) && !float_leads { // The escaping set at the container top: the first in-flow // child's top margin — and, when that child is a // self-collapsing empty block (CSS 2 §8.3.1: no lines, no @@ -190,19 +212,19 @@ pub(super) fn fold_through_collapsing_margins( layout .style(node.style) .map(|style| { - zero_padding(style.padding.top) - && zero_padding(style.padding.bottom) - && style.height == PreferredSize::Auto - && style.min_height == MinimumHeight::Auto + zero_padding(style.flow_padding().block_start) + && zero_padding(style.flow_padding().block_end) + && style.block_size() == PreferredSize::Auto + && style.min_block_size() == MinimumSize::Auto }) .unwrap_or(false) } - let mut accumulated: Option = None; + let mut accumulated: Option = None; // The container's own margin must be resolvable before any // child margin is zeroed: an unresolvable own margin used to // abort AFTER the children were stripped, dropping their // margins on the floor instead of leaving them in place. - let own = resolved_px(style.margin.top); + let own = resolved_px(block_margins(&style, writing).0); let in_flow_children: Vec = if own.is_some() { children .iter() @@ -217,26 +239,43 @@ pub(super) fn fold_through_collapsing_margins( .style(nodes[child.0 as usize].style) .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))?; let child_clear = child_style.clear; - let child_margin_top = child_style.margin.top; + let child_margin_top = block_margins(child_style, writing).0; // A clearing spacer with float siblings before it takes // clearance at layout time; the margins at and after it // then resolve against the cleared line (follower top = // float margin-box bottom + max(0, join(spacer bottom, // follower top) - spacer top), 32-case browser matrix), // so the fold must leave them in place for the layout - // pass instead of hoisting them onto the container. + // pass instead of hoisting them onto the container. A + // block with content that clears a float before it stops + // the escape too: the container settles its block offset + // above that block's margins (Blink's + // `HasClearancePastAdjoiningFloats`), which then act only + // inside the clearance, leaving the floats at the top. + let floats_before: Vec = children + .iter() + .copied() + .take_while(|id| *id != child) + .filter_map(|id| { + layout + .style(nodes[id.0 as usize].style) + .ok() + .map(|sibling| sibling.float) + .filter(|float| *float != Float::None) + }) + .collect(); + let clears_a_float_before = floats_before.iter().any(|float| { + matches!( + (child_clear, float), + (Clear::Both, _) + | (Clear::Left, Float::Left) + | (Clear::Right, Float::Right) + ) + }); if child_clear != Clear::None - && is_self_collapsing_empty(nodes, layout, child, &zero_padding) - && children - .iter() - .copied() - .take_while(|id| *id != child) - .any(|id| { - layout - .style(nodes[id.0 as usize].style) - .map(|sibling| sibling.float != Float::None) - .unwrap_or(false) - }) + && !floats_before.is_empty() + && (clears_a_float_before + || is_self_collapsing_empty(nodes, layout, child, &zero_padding)) { break; } @@ -246,38 +285,44 @@ pub(super) fn fold_through_collapsing_margins( let empty = is_self_collapsing_empty(nodes, layout, child, &zero_padding); let bottom = if empty { resolved_px( - layout - .style(nodes[child.0 as usize].style) - .map_err(|error| { - EpubError::new(format!("fold style resolves: {error}")) - })? - .margin - .bottom, + block_margins( + layout + .style(nodes[child.0 as usize].style) + .map_err(|error| { + EpubError::new(format!("fold style resolves: {error}")) + })?, + writing, + ) + .1, ) } else { None }; - accumulated = Some(join(accumulated.unwrap_or(0.0), top)); - set_margin(layout, nodes, child, Some(0.0), None)?; + let top = sets.top.remove(&child).unwrap_or(MarginSet::of(top)); + accumulated = Some(accumulated.unwrap_or_default().join(top)); + set_margin(layout, nodes, child, writing, Some(0.0), None)?; if !empty { break; } let Some(bottom) = bottom else { break; }; - accumulated = Some(join(accumulated.unwrap_or(0.0), bottom)); - set_margin(layout, nodes, child, None, Some(0.0))?; + let bottom = sets.bottom.remove(&child).unwrap_or(MarginSet::of(bottom)); + accumulated = Some(accumulated.unwrap_or_default().join(bottom)); + set_margin(layout, nodes, child, writing, None, Some(0.0))?; } if let (Some(escape), Some(own)) = (accumulated, own) { - if escape != 0.0 { - set_margin(layout, nodes, node, Some(join(own, escape)), None)?; + if !escape.is_zero() { + let set = MarginSet::of(own).join(escape); + set_margin(layout, nodes, node, writing, Some(set.value()), None)?; + sets.top.insert(node, set); } } } let bottom_open = !is_root - && zero_padding(style.padding.bottom) - && style.height == PreferredSize::Auto - && style.min_height == MinimumHeight::Auto; + && zero_padding(style.flow_padding().block_end) + && style.block_size() == PreferredSize::Auto + && style.min_block_size() == MinimumSize::Auto; if bottom_open { let last = children .iter() @@ -304,39 +349,63 @@ pub(super) fn fold_through_collapsing_margins( let last_style = layout .style(nodes[last.0 as usize].style) .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))?; - let escape = resolved_px(last_style.margin.bottom); - let own = layout - .style(nodes[node.0 as usize].style) - .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))? - .margin - .bottom; + let escape = resolved_px(block_margins(last_style, writing).1); + let own = block_margins( + layout + .style(nodes[node.0 as usize].style) + .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))?, + writing, + ) + .1; let own = resolved_px(own); if let (Some(escape), Some(own)) = (escape, own) { - if escape != 0.0 { - set_margin(layout, nodes, last, None, Some(0.0))?; - set_margin(layout, nodes, node, None, Some(join(own, escape)))?; + let escape = sets + .bottom + .get(&last) + .copied() + .unwrap_or(MarginSet::of(escape)); + if !escape.is_zero() { + sets.bottom.remove(&last); + let set = MarginSet::of(own).join(escape); + set_margin(layout, nodes, last, writing, None, Some(0.0))?; + set_margin(layout, nodes, node, writing, None, Some(set.value()))?; + sets.bottom.insert(node, set); } } } } - if is_root { - root_margins_to_padding(nodes, node, layout)?; - } Ok(()) } /// The chapter body's margins — its own plus what collapsed into it — /// become body padding: the browser offsets the whole flow by them /// (horizontally too), and padding carries identical geometry through /// this engine, applying on the first page only exactly like a margin - /// at an unforced fragmentainer start. + /// at an unforced fragmentainer start. Returns the top margin that + /// became padding with no padding of its own before it: the collapsed + /// margin above the body's first in-flow child. fn root_margins_to_padding( nodes: &mut [FormattingNode], root: FormattingNodeId, layout: &mut LayoutStyleTable, - ) -> EpubResult<()> { + ) -> EpubResult { let mut style = *layout .style(nodes[root.0 as usize].style) .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))?; + let writing = style.writing_direction(); + let leading = match ( + block_margins(&style, writing).0, + style.flow_padding().block_start.value(), + ) { + ( + LengthPercentageOrAuto::Value(LengthPercentage::Length(margin)), + LengthPercentage::Length(padding), + ) if padding.get() == 0.0 => f64::from(margin.get()).max(0.0), + ( + LengthPercentageOrAuto::Value(LengthPercentage::Length(margin)), + LengthPercentage::Percentage(padding), + ) if padding.ratio() == 0.0 => f64::from(margin.get()).max(0.0), + _ => 0.0, + }; let mut changed = false; let zero = || { LengthPercentageOrAuto::Value(LengthPercentage::Length( @@ -432,7 +501,62 @@ pub(super) fn fold_through_collapsing_margins( .intern(style) .map_err(|error| EpubError::new(format!("folded style interns: {error}")))?; } - Ok(()) + Ok(leading) + } + let root_mode = layout + .style(nodes[root.0 as usize].style) + .map_err(|error| EpubError::new(format!("fold style resolves: {error}")))? + .writing_mode; + fold( + nodes, + root, + layout, + true, + root_mode, + &mut FoldedSets::default(), + )?; + root_margins_to_padding(nodes, root, layout) +} + +/// An adjoining margin set (CSS 2 §8.3.1): its largest positive margin and +/// its most negative one, which collapse to their sum. Kept apart while +/// the fold lifts margins up several wrappers, so a negative margin deep +/// in the chain still cancels against the largest positive at any level +/// instead of being summed away at the first join. +#[derive(Clone, Copy, Debug, Default)] +struct MarginSet { + positive: f64, + negative: f64, +} + +impl MarginSet { + fn of(margin: f64) -> Self { + Self { + positive: margin.max(0.0), + negative: margin.min(0.0), + } + } + + fn join(self, other: Self) -> Self { + Self { + positive: self.positive.max(other.positive), + negative: self.negative.min(other.negative), + } + } + + fn value(self) -> f64 { + self.positive + self.negative + } + + fn is_zero(self) -> bool { + self.positive == 0.0 && self.negative == 0.0 } - fold(nodes, root, layout, true) +} + +/// The margin sets the fold has lifted onto a box's block-start and +/// block-end edges, which the box's own parent joins in turn. +#[derive(Default)] +struct FoldedSets { + top: std::collections::HashMap, + bottom: std::collections::HashMap, } diff --git a/crates/rito-core/src/fragment_bridge/styles.rs b/crates/rito-core/src/fragment_bridge/styles.rs index 04a827990..0484a0110 100644 --- a/crates/rito-core/src/fragment_bridge/styles.rs +++ b/crates/rito-core/src/fragment_bridge/styles.rs @@ -5,8 +5,8 @@ use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LengthPercentage, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, NonNegativeLengthPercentage, - Overflow, PageBreak, PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, NonNegativeLengthPercentage, Overflow, PageBreak, + PhysicalSides, Position, PreferredSize, WritingDirection, }; /// The marker text for one list item, or `None` when the list style @@ -101,7 +101,11 @@ pub(super) fn fallback_inline_formatting_style() -> rito_style_contract::InlineF style } -pub(super) fn anonymous_block_style() -> LayoutFormattingStyle { +/// The style of a block box the tree build generates (a wrapper around +/// a run of inline content, a chapter with no body): every box property +/// initial, laid out in the writing mode and direction of the box it sits +/// in, as an anonymous box inherits them. +pub(super) fn anonymous_block_style(writing: WritingDirection) -> LayoutFormattingStyle { let zero = LengthPercentageOrAuto::Value(LengthPercentage::Length( rito_style_contract::CssPx::new(0.0).expect("zero length is finite"), )); @@ -126,6 +130,9 @@ pub(super) fn anonymous_block_style() -> LayoutFormattingStyle { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -134,8 +141,11 @@ pub(super) fn anonymous_block_style() -> LayoutFormattingStyle { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: writing.mode, + direction: writing.direction, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, diff --git a/crates/rito-core/src/fragment_bridge/test_support.rs b/crates/rito-core/src/fragment_bridge/test_support.rs index 96d7c7bf2..15d3ec93c 100644 --- a/crates/rito-core/src/fragment_bridge/test_support.rs +++ b/crates/rito-core/src/fragment_bridge/test_support.rs @@ -1,7 +1,7 @@ //! Fixtures other in-crate tests build on: the plain block style they //! intern for containers. -use rito_style_contract::LayoutFormattingStyle; +use rito_style_contract::{LayoutFormattingStyle, WritingDirection}; use super::styles::anonymous_block_style; @@ -11,5 +11,5 @@ use super::styles::anonymous_block_style; /// The plain block style in-crate test fixtures intern for containers. #[cfg(test)] pub(crate) fn tests_block_style() -> LayoutFormattingStyle { - anonymous_block_style() + anonymous_block_style(WritingDirection::HORIZONTAL_LTR) } diff --git a/crates/rito-core/src/fragment_bridge/tests.rs b/crates/rito-core/src/fragment_bridge/tests.rs index 07d890f9b..b7e01e5a1 100644 --- a/crates/rito-core/src/fragment_bridge/tests.rs +++ b/crates/rito-core/src/fragment_bridge/tests.rs @@ -8,9 +8,11 @@ mod floated_images; mod flow; mod generated; mod inline; +mod inline_block; mod pagination; mod sizing; mod tree; +mod vertical_align; use super::*; use crate::render::test_support::css_color; @@ -74,6 +76,7 @@ fn resolved_chapter_with(xhtml: &str, css: &str) -> ResolvedChapter { manifest: Vec::new(), spine: Vec::new(), toc: Vec::new(), + page_progression: Default::default(), }, stylesheets: vec![LoadedTextResource { href: "styles/main.css".to_owned(), diff --git a/crates/rito-core/src/fragment_bridge/tests/flow.rs b/crates/rito-core/src/fragment_bridge/tests/flow.rs index cdad54209..4bbd99f37 100644 --- a/crates/rito-core/src/fragment_bridge/tests/flow.rs +++ b/crates/rito-core/src/fragment_bridge/tests/flow.rs @@ -504,3 +504,104 @@ fn observe_fixed_height_text_div_flow() { "the block after the fixed-height div flows at 74.8, got {last}" ); } + +/// A first in-flow block that clears the floats before it keeps its top +/// margin: the container settles its block offset above that margin +/// (Blink's `HasClearancePastAdjoiningFloats`), so the floats stay at +/// the container top and the margin only acts inside the clearance +/// (measured on 016's colour plates: `p.cb` with a 0.4em margin after +/// two right floats left the floats at their own 1em and 2em margins). +#[test] +fn a_block_clearing_the_floats_before_it_keeps_its_margin_inside() { + let chapter = resolved_chapter_with( + r#"t +
+
f
+

x

+
+"#, + "body { margin: 0; font-size: 10px; line-height: 10px; } p { margin: 6.4px 0; } .fr { float: right; margin-top: 16px; } .cb { clear: both; }\n", + ); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let engine = BlockFormattingContext::new( + ParleyInlineContext::new(vec![tinos_bytes()]).expect("fonts register"), + ); + let outcome = engine + .layout( + &built.tree, + built.tree.root(), + &ConstraintSpace::continuous(640.0), + None, + &CancelFlag::new(), + ) + .expect("lays out"); + fn line_ys(fragment: &Fragment, offset: f64, out: &mut Vec) { + match fragment { + Fragment::Box(node) => { + for child in &node.children { + line_ys(child, offset + node.rect.y, out); + } + } + Fragment::Line(line) => out.push(offset + line.rect.y), + _ => {} + } + } + let mut ys = Vec::new(); + line_ys(&outcome.fragments.root, 0.0, &mut ys); + ys.sort_by(f64::total_cmp); + assert_eq!(ys, vec![16.0, 26.0], "float line, then the cleared line"); +} + +/// Margins collapsing through several plain wrappers join as one set: +/// the largest positive plus the most negative (CSS 2 §8.3.1), whatever +/// level each sits at. Joining level by level into one number lost the +/// split (measured on 010's contents page: an `h2` 0.3em, a wrapper 1em, +/// an inner -0.5em and a paragraph 0.2em left the title 1.6px low). +#[test] +fn margins_through_nested_wrappers_collapse_as_one_set() { + let chapter = resolved_chapter_with( + r#"t +

x

+"#, + "body { margin: 0; font-size: 10px; line-height: 10px; } .outer { margin-top: 6px; } .mid { margin-top: 20px; } .inner { margin-top: -10px; } p { margin: 2px 0 0; }\n", + ); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let engine = BlockFormattingContext::new( + ParleyInlineContext::new(vec![tinos_bytes()]).expect("fonts register"), + ); + let outcome = engine + .layout( + &built.tree, + built.tree.root(), + &ConstraintSpace::continuous(640.0), + None, + &CancelFlag::new(), + ) + .expect("lays out"); + fn first_line_y(fragment: &Fragment, offset: f64) -> Option { + match fragment { + Fragment::Box(node) => node + .children + .iter() + .find_map(|child| first_line_y(child, offset + node.rect.y)), + Fragment::Line(line) => Some(offset + line.rect.y), + _ => None, + } + } + let y = first_line_y(&outcome.fragments.root, 0.0).expect("a line"); + assert_eq!(y, 10.0, "20 (largest positive) - 10 (most negative)"); +} diff --git a/crates/rito-core/src/fragment_bridge/tests/inline.rs b/crates/rito-core/src/fragment_bridge/tests/inline.rs index 4f45cd588..760f3e2a4 100644 --- a/crates/rito-core/src/fragment_bridge/tests/inline.rs +++ b/crates/rito-core/src/fragment_bridge/tests/inline.rs @@ -127,7 +127,9 @@ fn ruby_bases_carry_annotations_and_never_merge_with_neighbours() { ), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => { + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => { panic!("no atomic items here") } }) @@ -231,7 +233,9 @@ fn collapsible_space_after_a_forced_break_is_removed_at_the_line_start() { InlineItem::Text { text, .. } => text.as_str(), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => { + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => { panic!("no atomic items here") } }) @@ -267,7 +271,9 @@ fn pre_wrap_keeps_spaces_and_segment_breaks_verbatim() { InlineItem::Text { text, .. } => text.as_str(), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => { + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => { panic!("no atomic items here") } }) @@ -316,12 +322,26 @@ fn mono_ruby_pairs_each_annotation_with_its_base_segment() { ), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => { + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => { panic!("no atomic items here") } }) .collect(); assert_eq!(runs, vec![("漢", Some("かん")), ("字", Some("じ"))],); + // The second pair shares the element's box with the first. + let continues: Vec = items + .iter() + .filter_map(|item| match item { + InlineItem::Text { + ruby_annotation: Some(annotation), + .. + } => Some(annotation.continues_ruby), + _ => None, + }) + .collect(); + assert_eq!(continues, vec![false, true]); } #[test] @@ -405,3 +425,153 @@ fn right_aligned_line_starts_at_width_minus_advance() { "right-aligned start is exact: {line_x}" ); } + +/// The first paragraph's items, each as its text or a box edge mark: `<` +/// opens a box, `>` closes one, `∅` is a childless box. +fn first_paragraph_items(xhtml_body: &str, css: &str) -> Vec { + let chapter = resolved_chapter_with( + &format!( + r#"t{xhtml_body}"# + ), + css, + ); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let root = built.tree.node(built.tree.root()); + let FormattingNodeContent::InlineFlow { items } = &built.tree.node(root.children[0]).content + else { + panic!("first paragraph is an inline flow"); + }; + items + .iter() + .map(|item| match item { + InlineItem::Text { text, .. } => text.clone(), + InlineItem::OpenTag { .. } => "<".to_owned(), + InlineItem::CloseTag { .. } => ">".to_owned(), + InlineItem::EmptyBox { .. } => "∅".to_owned(), + InlineItem::Image { .. } | InlineItem::InlineBlock { .. } => "▣".to_owned(), + }) + .collect() +} + +/// A box with a start or end size opens or closes as an item of its own +/// (Blink's open and close tags), nested boxes each their own; a box +/// with neither adds nothing. +#[test] +fn a_sized_box_edge_is_an_item_of_its_own() { + let css = ".s { padding-left: 4px; } .e { padding-right: 4px; } .n { color: red; } \ + .b { border-left: 1px solid; } .m { margin: 0 2px; }"; + assert_eq!( + first_paragraph_items(r#"

abc

"#, css), + ["a", "<", "b", "c"] + ); + assert_eq!( + first_paragraph_items(r#"

abc

"#, css), + ["a", "b", ">", "c"] + ); + assert_eq!( + first_paragraph_items(r#"

abc

"#, css), + ["a", "b", "c"] + ); + assert_eq!( + first_paragraph_items( + r#"

ab

"#, + css + ), + ["a", "<", "<", "b", ">"] + ); + assert_eq!( + first_paragraph_items(r#"

ab

"#, css), + ["a", "<", "∅", ">", "b"] + ); +} + +/// A collapsed space stays where it arose: before a box opening after it, +/// inside a box closing after it, and inside a box it opens. +#[test] +fn a_collapsed_space_keeps_its_place_around_box_edges() { + let css = ".s { padding-left: 4px; } .e { padding-right: 4px; } em { font-style: italic; }"; + assert_eq!( + first_paragraph_items(r#"

a b

"#, css), + ["a ", "<", "b"] + ); + assert_eq!( + first_paragraph_items(r#"

a b

"#, css), + ["a", "<", " b"] + ); + assert_eq!( + first_paragraph_items(r#"

a b

"#, css), + ["a ", ">", "b"] + ); + assert_eq!( + first_paragraph_items(r#"

a b

"#, css), + ["a", " ", "<", "b"] + ); + assert_eq!( + first_paragraph_items(r#"

a
b

"#, css), + ["a\n", "<", "b"] + ); + assert_eq!( + first_paragraph_items( + r#"

a b

"#, + css + ), + ["a ", "<", "b"] + ); +} + +/// A missing image lays out as Chromium's broken-image placeholder by its +/// alt: none keeps the bare 16px icon, an empty alt collapses, and alt +/// text follows the icon. +#[test] +fn a_missing_image_lays_out_by_its_alt() { + let chapter = resolved_chapter_with( + r#"t +

x

"#, + "", + ); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let mut sizes = Vec::new(); + let mut texts = Vec::new(); + let mut stack = vec![built.tree.root()]; + while let Some(id) = stack.pop() { + let node = built.tree.node(id); + stack.extend(node.children.iter().copied()); + if let FormattingNodeContent::InlineFlow { items } = &node.content { + for item in items { + match item { + InlineItem::Image { + src, + intrinsic_width, + intrinsic_height, + .. + } => sizes.push((src.clone(), *intrinsic_width, *intrinsic_height)), + InlineItem::Text { text, .. } => texts.push(text.clone()), + _ => {} + } + } + } + } + assert_eq!( + sizes, + vec![ + ("none.jpg".to_owned(), 16.0, 16.0), + ("empty.jpg".to_owned(), 1.0, 1.0), + ("text.jpg".to_owned(), 16.0, 16.0), + ] + ); + assert_eq!(texts, vec!["x".to_owned()]); +} diff --git a/crates/rito-core/src/fragment_bridge/tests/inline_block.rs b/crates/rito-core/src/fragment_bridge/tests/inline_block.rs new file mode 100644 index 000000000..ce851ff78 --- /dev/null +++ b/crates/rito-core/src/fragment_bridge/tests/inline_block.rs @@ -0,0 +1,187 @@ +//! Inline blocks as Blink lays them out: an atomic inline whatever the +//! element, sized by its own `width` and `height`, standing on the line +//! when empty, and holding block-level boxes as a block container. + +use super::*; + +fn lay_out(xhtml: &str, css: &str, space: &ConstraintSpace) -> Fragment { + let chapter = resolved_chapter_with(xhtml, css); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let engine = BlockFormattingContext::new( + ParleyInlineContext::new(vec![tinos_bytes()]).expect("fonts register"), + ); + engine + .layout( + &built.tree, + built.tree.root(), + space, + None, + &CancelFlag::new(), + ) + .expect("lays out") + .fragments + .root +} + +/// Every line box of the tree, with its block offset in the root's +/// coordinates. +fn lines(fragment: &Fragment) -> Vec<(f64, &rito_fragment::LineFragment)> { + fn walk<'a>( + fragment: &'a Fragment, + offset: f64, + out: &mut Vec<(f64, &'a rito_fragment::LineFragment)>, + ) { + match fragment { + Fragment::Box(node) => { + for child in &node.children { + walk(child, offset + node.rect.y, out); + } + } + Fragment::Line(line) => out.push((offset + line.rect.y, line)), + _ => {} + } + } + let mut out = Vec::new(); + walk(fragment, 0.0, &mut out); + out +} + +fn atoms(line: &rito_fragment::LineFragment) -> Vec<&rito_fragment::BoxFragment> { + line.children + .iter() + .filter_map(|child| match child { + Fragment::Box(atom) => Some(atom), + _ => None, + }) + .collect() +} + +/// A `
` styled `display: inline-block` rides its parent's line between +/// the text around it, its block children laid out inside it. +#[test] +fn an_inline_block_div_rides_its_parents_line() { + let root = lay_out( + r#"t +
a
x
y
b
+"#, + "body { margin: 0; font-size: 10px; line-height: 10px; } .ib { display: inline-block; }\n", + &ConstraintSpace::continuous(640.0), + ); + let lines = lines(&root); + let host = lines + .iter() + .find(|(_, line)| !atoms(line).is_empty()) + .expect("a line holds the inline block"); + let atom = atoms(host.1)[0]; + let inner: Vec<_> = atom + .children + .iter() + .filter(|child| matches!(child, Fragment::Box(_))) + .collect(); + assert_eq!(inner.len(), 2, "both block children inside the atom"); + assert_eq!(atom.rect.height, 20.0, "two 10px lines tall"); + let texts = host + .1 + .children + .iter() + .filter(|child| matches!(child, Fragment::Text(_))) + .count(); + assert!(texts >= 2, "the text on both sides shares its line"); +} + +/// An empty inline-block stands on the line at its specified size, +/// pushing the text after it along (Blink keeps it as an atomic inline). +#[test] +fn an_empty_inline_block_takes_its_specified_size() { + let root = lay_out( + r#"t +
b
+"#, + "body { margin: 0; font-size: 10px; line-height: 10px; } .ib { display: inline-block; width: 30px; height: 20px; }\n", + &ConstraintSpace::continuous(640.0), + ); + let lines = lines(&root); + assert_eq!(lines.len(), 1); + let line = lines[0].1; + let atom = atoms(line)[0]; + assert_eq!((atom.rect.width, atom.rect.height), (30.0, 20.0)); + let text_x = line + .children + .iter() + .find_map(|child| match child { + Fragment::Text(text) => Some(text.rect.x), + _ => None, + }) + .expect("the text after it"); + assert_eq!(text_x, 30.0); +} + +/// A horizontal inline-block on a vertical line resolves `width: 100%` +/// against the initial containing block when the line's block size is +/// indefinite, as Blink's orthogonal fallback does — the page, here — so +/// calibre's empty plate spacer makes its line as wide as the page. +#[test] +fn an_orthogonal_spacer_spans_the_page_across_its_line() { + let root = lay_out( + r#"t +
x
+"#, + "html, body { writing-mode: vertical-rl; } body { margin: 0; font-size: 10px; line-height: 10px; } .spacer { writing-mode: horizontal-tb; display: inline-block; width: 100%; }\n", + &ConstraintSpace::fragmented(850.0, 640.0), + ); + let lines = lines(&root); + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0].1.rect.height, 640.0, + "the line stands as wide as the page" + ); +} + +/// The shrink-to-fit width of a floated `
` holding `body`. +fn float_width(body: &str) -> f64 { + let root = lay_out( + &format!( + r#"t
{body}
"# + ), + "body { margin: 0; font-size: 10px; line-height: 10px; } .f { float: left; } .ib { display: inline-block; }\n", + &ConstraintSpace::continuous(640.0), + ); + let Fragment::Box(root) = &root else { + panic!("the root is a box"); + }; + root.children + .iter() + .find_map(|child| match child { + Fragment::Box(float) => Some(float.rect.width), + _ => None, + }) + .expect("the float") +} + +/// An inline-block right after a forced break starts the next line in +/// the max-content width too, so a shrink-to-fit box takes the wider of +/// the two lines, not their sum (Chromium: `a
` sizes by +/// the longer one), and an open tag there brings its padding along. +#[test] +fn an_inline_block_after_a_forced_break_measures_on_the_next_line() { + let first = float_width("aaaaaaaaaa"); + assert_eq!( + float_width(r#"aaaaaaaaaa
bbbbb
"#), + first + ); + assert_eq!( + float_width(r#"aaaaaaaaaa
bbbbbbbbbb"#), + float_width(r#"bbbbbbbbbb"#) + ); + assert_eq!( + float_width(r#"aaa
bbb"#), + float_width(r#"bbb"#) + ); +} diff --git a/crates/rito-core/src/fragment_bridge/tests/pagination.rs b/crates/rito-core/src/fragment_bridge/tests/pagination.rs index 13342dfc0..ad31961a6 100644 --- a/crates/rito-core/src/fragment_bridge/tests/pagination.rs +++ b/crates/rito-core/src/fragment_bridge/tests/pagination.rs @@ -64,7 +64,9 @@ fn real_chapter_paginates_losslessly_through_the_new_engine() { InlineItem::Text { text, .. } => Some(text.as_str()), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => None, + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => None, }) .collect(); let mut start = u32::MAX; @@ -96,3 +98,53 @@ fn real_chapter_paginates_losslessly_through_the_new_engine() { ], ); } + +/// A block whose start lands past the page's end — here an empty one after +/// a line taller than the page — breaks before and opens the next page, +/// however small, as Blink must when the space left is below zero +/// (measured: calibre's empty page-break `
` after an over-wide plate +/// takes a column of its own). +#[test] +fn an_empty_block_past_an_overflowing_line_opens_the_next_page() { + let chapter = resolved_chapter_with( + r#"t +
+"#, + "body { margin: 0; font-size: 10px; line-height: 10px; } .tall { display: inline-block; height: 100px; }\n", + ); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let engine = BlockFormattingContext::new( + ParleyInlineContext::new(vec![tinos_bytes()]).expect("fonts register"), + ); + let space = ConstraintSpace::fragmented(200.0, 60.0); + let first = engine + .layout( + &built.tree, + built.tree.root(), + &space, + None, + &CancelFlag::new(), + ) + .expect("first page lays out"); + let token = first + .continuation + .expect("the empty block opens a second page"); + assert_eq!(token.stage, rito_fragment::BreakTokenStage::Before); + let second = engine + .layout( + &built.tree, + built.tree.root(), + &space, + Some(&token), + &CancelFlag::new(), + ) + .expect("second page lays out"); + assert!(second.continuation.is_none()); +} diff --git a/crates/rito-core/src/fragment_bridge/tests/sizing.rs b/crates/rito-core/src/fragment_bridge/tests/sizing.rs index fb833da67..615a44298 100644 --- a/crates/rito-core/src/fragment_bridge/tests/sizing.rs +++ b/crates/rito-core/src/fragment_bridge/tests/sizing.rs @@ -209,3 +209,43 @@ fn modern_sizing_keeps_numeric_constraints_and_authored_styles() { ); } } + +/// A minimum block size sits on the LayoutUnit grid by truncation, as +/// Blink stores it: paragraphs held to `min-height: 1.6em` at 16px stack +/// 25.59375 apart, not 25.6 (measured on a vertical book whose every +/// paragraph is held to `min-width: 1.6em`: the excess piled up over a page +/// and moved where the next page's lines started). +#[test] +fn a_minimum_block_size_truncates_onto_the_layout_unit_grid() { + let chapter = resolved_chapter_with( + r#"t +

x

+"#, + "body { margin: 0; font-size: 16px; line-height: 10px; } p { margin: 0; min-height: 1.6em; }\n", + ); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &no_images(), + ) + .expect("tree builds"); + let engine = BlockFormattingContext::new( + ParleyInlineContext::new(vec![tinos_bytes()]).expect("fonts register"), + ); + let outcome = engine + .layout( + &built.tree, + built.tree.root(), + &ConstraintSpace::continuous(640.0), + None, + &CancelFlag::new(), + ) + .expect("lays out"); + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("a box root"); + }; + let tops: Vec = root.children.iter().map(|child| child.rect().y).collect(); + assert_eq!(tops, vec![0.0, 25.59375, 51.1875]); +} diff --git a/crates/rito-core/src/fragment_bridge/tests/tree.rs b/crates/rito-core/src/fragment_bridge/tests/tree.rs index 4cb7eafad..5a10aa158 100644 --- a/crates/rito-core/src/fragment_bridge/tests/tree.rs +++ b/crates/rito-core/src/fragment_bridge/tests/tree.rs @@ -87,7 +87,9 @@ fn chapter_tree_reflects_box_generation_and_display_none() { InlineItem::Text { text, .. } => text.as_str(), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => { + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => { panic!("no atomic items in this paragraph") } }) diff --git a/crates/rito-core/src/fragment_bridge/tests/vertical_align.rs b/crates/rito-core/src/fragment_bridge/tests/vertical_align.rs new file mode 100644 index 000000000..2e0ac36c3 --- /dev/null +++ b/crates/rito-core/src/fragment_bridge/tests/vertical_align.rs @@ -0,0 +1,103 @@ +//! `vertical-align: middle` on atomic inlines: the box's middle half its +//! parent's x-height above the parent's baseline, placed as Chromium +//! places it (offsets measured in pinned Chromium on 10px Tinos lines +//! 16px tall). + +use super::*; + +/// The first atom's top and bottom against its line's baseline (down +/// positive), and the line's height. +fn atom_against_baseline(body: &str, writing_mode: &str) -> (f64, f64, f64) { + let chapter = resolved_chapter_with( + &format!( + r#"t
{body}
"# + ), + &format!( + "body {{ margin: 0; font-family: Tinos; font-size: 10px; line-height: 16px; writing-mode: {writing_mode}; }} .m {{ vertical-align: middle; }}\n" + ), + ); + let images = BTreeMap::from([("px.png".to_owned(), (2, 2))]); + let built = build_chapter_formatting_tree( + &chapter.nodes, + chapter.body_index, + &chapter.layout, + &chapter.inline, + &images, + ) + .expect("tree builds"); + let engine = BlockFormattingContext::new( + ParleyInlineContext::new(vec![tinos_bytes()]).expect("fonts register"), + ); + let root = engine + .layout( + &built.tree, + built.tree.root(), + &ConstraintSpace::continuous(640.0), + None, + &CancelFlag::new(), + ) + .expect("lays out") + .fragments + .root; + fn find(fragment: &Fragment) -> Option<(f64, f64, f64)> { + match fragment { + Fragment::Box(node) => node.children.iter().find_map(find), + Fragment::Line(line) => line.children.iter().find_map(|child| { + let rect = match child { + Fragment::Image(image) => image.rect, + Fragment::Box(atom) => atom.rect, + _ => return None, + }; + Some(( + rect.y - line.baseline, + rect.y + rect.height - line.baseline, + line.rect.height, + )) + }), + _ => None, + } + } + find(&root).expect("an atom on a line") +} + +#[test] +fn a_middle_image_centres_half_the_x_height_above_the_baseline() { + assert_eq!( + atom_against_baseline( + r#"xx"#, + "horizontal-tb" + ), + (-17.296875, 12.703125, 30.0) + ); +} + +#[test] +fn a_middle_inline_block_centres_by_its_last_baseline() { + assert_eq!( + atom_against_baseline( + r#"xx"#, + "horizontal-tb" + ), + (-17.296875, 12.703125, 30.0) + ); + let (top, bottom, _) = atom_against_baseline( + r#"xAb
Cd
x"#, + "horizontal-tb", + ); + assert_eq!((top, bottom), (-18.296875, 13.703125)); +} + +/// The x-height is the parent box's, not the atom's own font. +#[test] +fn middle_takes_the_parent_box_x_height() { + let (top, bottom, _) = atom_against_baseline( + r#"xx"#, + "horizontal-tb", + ); + assert_eq!((top, bottom), (-17.296875, 12.703125)); + let (top, bottom, _) = atom_against_baseline( + r#"xy"#, + "horizontal-tb", + ); + assert_eq!((top, bottom), (-19.59375, 10.40625)); +} diff --git a/crates/rito-core/src/fragment_pagination.rs b/crates/rito-core/src/fragment_pagination.rs index eedaac469..a60a2e190 100644 --- a/crates/rito-core/src/fragment_pagination.rs +++ b/crates/rito-core/src/fragment_pagination.rs @@ -10,10 +10,14 @@ use rito_fragment::{ BreakToken, CancelFlag, ConstraintSpace, FormattingContext, FormattingTree, Fragment, LineText, + WritingModeConverter, }; +use rito_style_contract::{Direction, WritingDirection, WritingMode}; use crate::epub::{EpubError, EpubResult}; -use crate::fragment_paint::{append_fragment_display_commands, FragmentPaintContext}; +use crate::fragment_paint::{ + append_fragment_display_commands, FragmentPaintContext, VerticalFrame, +}; use crate::render::DisplayCommand; /// One paginated page: the sealed fragment tree that fit the page's @@ -36,9 +40,7 @@ pub(crate) fn chapter_is_vertical(tree: &FormattingTree) -> bool { let strut = first_inline_strut(tree, tree.root())?; tables.inline.style(strut).ok() }) - .is_some_and(|style| { - style.bidi.writing_mode == rito_style_contract::WritingMode::VerticalRightToLeft - }) + .is_some_and(|style| style.bidi.writing_mode == WritingMode::VerticalRightToLeft) } /// Paints one paginated page into display commands at the page's content @@ -53,8 +55,21 @@ pub(crate) fn paint_chapter_page( paint_context: FragmentPaintContext<'_>, vertical: bool, ) -> EpubResult> { + // The swapped page's root fragment spans the column length inline and + // the content width across. let paint_context = FragmentPaintContext { - vertical_frame: vertical.then_some((origin_x + content_width, origin_y)), + vertical_frame: vertical.then(|| VerticalFrame { + converter: WritingModeConverter::from_logical_size( + WritingDirection { + mode: WritingMode::VerticalRightToLeft, + direction: Direction::LeftToRight, + }, + root.rect().width, + content_width, + ), + left: origin_x, + top: origin_y, + }), ..paint_context }; let (origin_x, origin_y) = if vertical { @@ -215,6 +230,34 @@ impl ChapterPaginator { } } +/// The whole chapter laid out as one continuous strip: as wide as a page's +/// content box, never broken, and knowing how tall one is, so images scale +/// against it and `vh` resolves exactly as they do on a page. Its lines +/// hold their text fragments as `line_text` says, a range of lines in the +/// strip's own coordinates; the others are laid out alike without them. +pub(crate) fn layout_chapter_strip( + engine: &dyn FormattingContext, + tree: &FormattingTree, + content_width: f64, + content_height: f64, + line_text: LineText, + cancel: &CancelFlag, +) -> EpubResult { + let page = page_space(tree, content_width, content_height, false); + let space = ConstraintSpace { + fragmentainer_size: page.fragmentainer_size, + line_text, + ..ConstraintSpace::continuous(page.inline_size) + }; + let outcome = engine + .layout(tree, tree.root(), &space, None, cancel) + .map_err(|error| EpubError::new(format!("chapter strip layout failed: {error:?}")))?; + if outcome.continuation.is_some() { + return Err(EpubError::new("a continuous chapter strip broke")); + } + Ok(outcome.fragments.root) +} + /// One page of `tree` laid out with its text from `start`, where /// pagination resumed it, and where the chapter resumes after it. pub(crate) fn layout_chapter_page( @@ -245,8 +288,8 @@ mod tests { AlignItems, BoxSizing, Clear, CssPx, Float, FontFamilies, FontFamily, FontFamilyName, InlineStyleTable, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleTable, LengthPercentage, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, NonNegativeLengthPercentage, - Overflow, PageBreak, PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, NonNegativeLengthPercentage, Overflow, + PageBreak, PhysicalSides, Position, PreferredSize, }; fn plain_block_layout_style() -> LayoutFormattingStyle { @@ -275,6 +318,9 @@ mod tests { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -283,8 +329,11 @@ mod tests { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, diff --git a/crates/rito-core/src/fragment_paint.rs b/crates/rito-core/src/fragment_paint.rs index f2f873326..0eebf3aa6 100644 --- a/crates/rito-core/src/fragment_paint.rs +++ b/crates/rito-core/src/fragment_paint.rs @@ -18,7 +18,7 @@ //! typed run paint from a computed style) and `family` (the face each //! piece of a run paints with). -use rito_fragment::{FormattingTree, Fragment}; +use rito_fragment::{FormattingTree, Fragment, FragmentRect, WritingModeConverter}; use std::collections::BTreeMap; @@ -26,7 +26,11 @@ use crate::epub::EpubResult; use crate::fragment_bridge::{FlowItemSource, NodePaint}; use crate::render::DisplayCommand; +mod box_slice; +mod emphasis; mod family; +pub(crate) use family::{combined_forms_family, vertical_forms_family}; +mod flow_text; mod image; mod line; mod run_style; @@ -34,8 +38,10 @@ mod run_style; mod tests; mod text_run; mod vertical; +mod vertical_decoration; mod walk; +use flow_text::FlowTexts; use walk::append_fragment_display_commands_inner; /// Fraction of the font size between a run's alphabetic baseline and the @@ -63,15 +69,13 @@ pub(crate) struct FragmentPaintContext<'a> { /// right-aligned against the item's content-left edge on its first /// line's baseline. pub(crate) list_markers: Option<&'a BTreeMap>, - /// Ruby annotations shaped at their own size, keyed by (inline-flow - /// node id, item index): the natural cluster origins the painter - /// distributes over each base segment by `ruby-align`. - pub(crate) ruby_annotation_runs: Option<&'a BTreeMap<(u32, usize), rito_inline::MeasuredRuby>>, - /// `Some((right, top))` when the tree laid out as a vertical-rl - /// chapter in the swapped page: recursion origins then accumulate - /// LOGICAL (inline, block) offsets and every line paints as a column - /// placed `block` in from `right`, `inline` down from `top`. - pub(crate) vertical_frame: Option<(f64, f64)>, + /// What paints beside lines' text: ruby annotations and emphasis + /// marks. + pub(crate) annotations: LineAnnotations<'a>, + /// Set when the tree laid out as a vertical-rl chapter: recursion + /// origins then accumulate flow-relative (inline, block) offsets, and + /// every box and line lands on the page through the frame. + pub(crate) vertical_frame: Option, /// Per-flow item provenance keyed by inline-flow node id — the map /// the artifact builder reads. Painted text and image commands carry /// the nearest enclosing link's target (and an image's alt text) so @@ -88,6 +92,19 @@ pub(crate) struct FragmentPaintContext<'a> { pub(crate) ratio: f64, } +/// The strings that paint beside a line's text, measured when the chapter +/// was built. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct LineAnnotations<'a> { + /// Ruby annotations shaped at their own size, keyed by (inline-flow + /// node id, item index): the natural cluster origins the painter + /// distributes over each base segment by `ruby-align`. + pub(crate) ruby: Option<&'a BTreeMap<(u32, usize), rito_inline::MeasuredRuby>>, + /// The emphasis mark of each inline style that takes one. + pub(crate) emphasis: + Option<&'a BTreeMap>, +} + impl Default for FragmentPaintContext<'_> { fn default() -> Self { Self { @@ -95,7 +112,7 @@ impl Default for FragmentPaintContext<'_> { node_paints: None, image_border_paints: None, list_markers: None, - ruby_annotation_runs: None, + annotations: LineAnnotations::default(), vertical_frame: None, flow_item_sources: None, ratio: 1.0, @@ -103,6 +120,47 @@ impl Default for FragmentPaintContext<'_> { } } +/// Where a chapter laid out in a vertical writing mode lands on the page: +/// its fragments' flow-relative rectangles convert to physical ones within +/// the page's content box, whose top-left corner is `(left, top)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct VerticalFrame { + pub(crate) converter: WritingModeConverter, + pub(crate) left: f64, + pub(crate) top: f64, +} + +impl FragmentPaintContext<'_> { + /// A box's rectangle at the walk's accumulated offsets, as the page + /// rectangle it paints: as laid out in horizontal flow, converted + /// through the vertical frame in a vertical chapter. + pub(crate) fn page_rect( + &self, + x: f64, + y: f64, + width: f64, + height: f64, + ) -> (f64, f64, f64, f64) { + match self.vertical_frame { + None => (x, y, width, height), + Some(frame) => { + let rect = frame.converter.to_physical(FragmentRect { + x, + y, + width, + height, + }); + ( + frame.left + rect.x, + frame.top + rect.y, + rect.width, + rect.height, + ) + } + } + } +} + /// The browser's paint-offset snap: a CSS-px coordinate rounded to the /// nearest whole CSS pixel, at any device ratio. pub(crate) fn snap_css(value: f64) -> f64 { @@ -148,6 +206,13 @@ pub(crate) fn append_fragment_display_commands( context: FragmentPaintContext<'_>, ) -> EpubResult<()> { append_fragment_display_commands_inner( - commands, tree, fragment, origin_x, origin_y, context, 0.0, + commands, + tree, + fragment, + origin_x, + origin_y, + context, + 0.0, + &FlowTexts::default(), ) } diff --git a/crates/rito-core/src/fragment_paint/box_slice.rs b/crates/rito-core/src/fragment_paint/box_slice.rs new file mode 100644 index 000000000..6c4736e3e --- /dev/null +++ b/crates/rito-core/src/fragment_paint/box_slice.rs @@ -0,0 +1,58 @@ +//! A box fragment a fragmentainer break cut: the cut edges belong to the +//! box's neighbouring fragments, so this one strokes no border there +//! (`box-decoration-break: slice`, Blink's `SetSidesToInclude`). Its +//! background still fills to the cut, where the box runs on. + +use rito_fragment::BlockCuts; + +use crate::render::contract::{ReaderBlockPaint, ReaderBorderBox}; + +use super::VerticalFrame; + +/// Drops the border on each of `paint`'s cut edges: the block-start and +/// block-end edges are the top and bottom of a horizontal page, the right +/// and left of a vertical-rl one, the left and right of a vertical-lr one. +pub(super) fn slice_cut_edges( + paint: &mut ReaderBlockPaint, + border_box: &mut Option, + cuts: BlockCuts, + frame: Option<&VerticalFrame>, +) { + if !cuts.start && !cuts.end { + return; + } + let (start, end) = match frame { + None => (Side::Top, Side::Bottom), + Some(frame) if frame.converter.writing().is_flipped_blocks() => (Side::Right, Side::Left), + Some(_) => (Side::Left, Side::Right), + }; + for (cut, side) in [(cuts.start, start), (cuts.end, end)] { + if !cut { + continue; + } + if let Some(border) = paint.border.as_mut() { + *match side { + Side::Top => &mut border.top, + Side::Right => &mut border.right, + Side::Bottom => &mut border.bottom, + Side::Left => &mut border.left, + } = None; + } + if let Some(widths) = border_box.as_mut() { + *match side { + Side::Top => &mut widths.top_width, + Side::Right => &mut widths.right_width, + Side::Bottom => &mut widths.bottom_width, + Side::Left => &mut widths.left_width, + } = 0.0; + } + } +} + +#[derive(Clone, Copy)] +enum Side { + Top, + Right, + Bottom, + Left, +} diff --git a/crates/rito-core/src/fragment_paint/emphasis.rs b/crates/rito-core/src/fragment_paint/emphasis.rs new file mode 100644 index 000000000..bbdc184c2 --- /dev/null +++ b/crates/rito-core/src/fragment_paint/emphasis.rs @@ -0,0 +1,344 @@ +//! Emphasis marks painted beside the text they mark, where Blink's text +//! painter puts them (`TextPainter::SetEmphasisMark`, +//! `ShapeResultBloberizer::FillTextEmphasisGlyphs`): one mark for every +//! grapheme that takes one, at the centre of the grapheme's share of its +//! cluster's advance, the point the marks hang from lying outside the +//! text's em box by the mark's typo descent or ascent. A horizontal +//! line's mark sits on that point's baseline, its outline centred on the +//! grapheme less half the letter spacing; an upright mark in a vertical +//! line centres its outline on the point moved line-over by its face's +//! vertical baseline offset (`MeasuredEmphasis::upright_offset`); a mark +//! lying with a vertical +//! line's text sits like a horizontal line's, turned a quarter clockwise. +//! Marks paint as annotation text, after the text they mark. + +use rito_fragment::{ + FontFaceId, FormattingTreeStyles, Fragment, InlineItem, LineFragment, TextFragment, +}; +use rito_inline::MeasuredEmphasis; +use rito_style_contract::{InlineFormattingStyle, LengthPercentage, LineSide}; + +use crate::epub::EpubResult; +use crate::render::contract::{ReaderPoint, ReaderSize, ReaderTransform}; +use crate::render::{display_number, display_rect, DisplayCommand, DisplayTextCommand, RunPaint}; + +use super::family::{css_color, face_family, vertical_forms_family}; +use super::CANVAS_TOP_ASCENT_RATIO; + +/// The start byte (in the run's text) and the origin along the line of +/// each of a run's clusters, `origin` being where the line starts: the +/// pen's float sum of shaped advances, before any whole-grid placement +/// of the glyphs (a mark centres on the shaped advance, Blink's +/// `ForEachGraphemeClusters`). +pub(super) fn cluster_origins(run: &TextFragment, origin: f64) -> Vec<(usize, f64)> { + run.clusters_with_origins() + .map(|(segment_x, cluster)| { + ( + (cluster.byte - run.text_start) as usize, + origin + segment_x + run.ruby_center_shift_px + cluster.x, + ) + }) + .collect() +} + +/// Where along the line the marks of a run's graphemes centre: each +/// cluster's advance (to the next cluster's origin, the last to `end`) +/// shared evenly among its graphemes, a mark at the middle of the share of +/// every grapheme that takes one. `clusters` holds each cluster's start +/// byte in `text` and its origin along the line. +pub(super) fn mark_places(text: &str, clusters: &[(usize, f64)], end: f64) -> Vec { + let segmenter = icu_segmenter::GraphemeClusterSegmenter::new(); + let mut places = Vec::new(); + for (index, &(start, origin)) in clusters.iter().enumerate() { + let (stop, next) = clusters + .get(index + 1) + .map_or((text.len(), end), |&(byte, origin)| (byte, origin)); + let Some(cluster) = text.get(start..stop) else { + continue; + }; + let breaks: Vec = segmenter.segment_str(cluster).collect(); + let graphemes = breaks.len().saturating_sub(1); + if graphemes == 0 { + continue; + } + let share = (next - origin) / graphemes as f64; + for (grapheme, bounds) in breaks.windows(2).enumerate() { + let takes = cluster[bounds[0]..bounds[1]] + .chars() + .next() + .is_some_and(rito_inline::takes_emphasis_mark); + if takes { + places.push(origin + share * (grapheme as f64 + 0.5)); + } + } + } + places +} + +/// Where the advance of a run's last cluster ends along the line, the +/// line starting at `origin`: where the line's text resumes after the run, +/// less the inline box edges between them, when more of the line's text +/// follows it — a justified line's share after the cluster belongs to its +/// shaped advance (Blink's expansion opportunity) though the run's rect +/// stops short of it — and the run's own end, `end`, otherwise. +pub(super) fn last_advance_end( + line: &LineFragment, + run: &TextFragment, + items: &[InlineItem], + item_ranges: &[(std::ops::Range, usize)], + styles: &FormattingTreeStyles, + origin: f64, + end: f64, +) -> f64 { + let item_of = |byte: u32| { + item_ranges + .iter() + .find(|(range, _)| range.contains(&(byte as usize))) + .map(|(_, index)| *index) + }; + let next = line.children.iter().find_map(|child| match child { + Fragment::Text(next) if next.text_start == run.text_end => Some(next), + _ => None, + }); + let Some(next) = next else { + return end; + }; + let (Some(first), Some(this_item), Some(next_item)) = ( + cluster_origins(next, origin).first().copied(), + item_of(run.text_start), + item_of(next.text_start), + ) else { + return end; + }; + let edges: f64 = items + .get(this_item + 1..next_item) + .unwrap_or_default() + .iter() + .filter_map(|item| match item { + InlineItem::OpenTag { style } => Some((style, false)), + InlineItem::CloseTag { style } => Some((style, true)), + _ => None, + }) + .filter_map(|(style, closes)| { + let style = styles.inline.style(*style).ok()?; + Some(f64::from(style.inline_edge(closes).size(None))) + }) + .sum(); + first.1 - edges +} + +/// The faces a run's clusters were shaped with. +pub(super) fn run_faces(run: &TextFragment) -> impl Iterator + '_ { + run.clusters.iter().map(|cluster| cluster.face) +} + +/// The paint the marks of text in `style` take: the mark's face at its +/// size, in the emphasis color. +fn marks_paint( + style: &InlineFormattingStyle, + measured: &MeasuredEmphasis, + family: std::sync::Arc, + run_paint: &RunPaint, +) -> EpubResult { + let Some(emphasis) = &style.text_flow.text_emphasis else { + return Ok(run_paint.for_marks(measured.size, run_paint.color)); + }; + let color = css_color(emphasis.color.resolve(style.paint.foreground))?; + Ok(run_paint + .for_marks(measured.size, color) + .with_family(family)) +} + +/// The marks command of a run on a horizontal line whose glyphs paint on +/// `baseline`, its marks centred at `places` along the line. +#[allow(clippy::too_many_arguments)] +pub(super) fn horizontal_marks( + style: &InlineFormattingStyle, + measured: &MeasuredEmphasis, + faces: Option<&[std::sync::Arc]>, + run_paint: &RunPaint, + run_faces: impl Iterator, + places: &[f64], + baseline: f64, +) -> EpubResult> { + let (Some(first), Some(last)) = (places.first(), places.last()) else { + return Ok(None); + }; + let distance = measured.distance(run_faces); + let mark_baseline = match measured.side { + LineSide::Over => baseline - distance, + LineSide::Under => baseline + distance, + }; + // Each mark's outline centres on its grapheme less half the letter + // spacing the grapheme's advance carries. + let shift = letter_spacing(style) / 2.0 + measured.ink_center.0; + let mark = first_char(&measured.mark); + let paint = marks_paint( + style, + measured, + face_family(style, faces, measured.face)?, + run_paint, + )?; + Ok(Some(marks_command( + &mark, + places + .iter() + .map(|place| (place - shift, mark_baseline)) + .collect(), + display_rect( + first - shift, + mark_baseline - CANVAS_TOP_ASCENT_RATIO * measured.size, + last - first + measured.size, + measured.size, + ), + paint, + ))) +} + +/// The marks commands of a run in a vertical line whose central baseline +/// lies at `baseline_x` across the page, its marks centred at `places` +/// down the column. +#[allow(clippy::too_many_arguments)] +pub(super) fn vertical_marks( + style: &InlineFormattingStyle, + measured: &MeasuredEmphasis, + faces: Option<&[std::sync::Arc]>, + run_paint: &RunPaint, + run_faces: impl Iterator, + places: &[f64], + baseline_x: f64, +) -> EpubResult> { + let (Some(first), Some(last)) = (places.first(), places.last()) else { + return Ok(Vec::new()); + }; + let distance = measured.distance(run_faces); + // The line-over side of a vertical line is its right. + let across = match measured.side { + LineSide::Over => baseline_x + distance, + LineSide::Under => baseline_x - distance, + }; + let mark = first_char(&measured.mark); + let family = face_family(style, faces, measured.face)?; + let (center_x, center_y) = measured.ink_center; + if measured.upright { + let family = match faces { + Some(_) => std::sync::Arc::from(vertical_forms_family(&family)), + None => family, + }; + let paint = marks_paint(style, measured, family, run_paint)?; + return Ok(vec![marks_command( + &mark, + places + .iter() + .map(|place| { + ( + across + measured.upright_offset - center_x, + place - center_y, + ) + }) + .collect(), + display_rect( + across - measured.size / 2.0, + first - measured.size / 2.0, + measured.size, + last - first + measured.size, + ), + paint, + )]); + } + // Lying with the text: set horizontally down from the first mark's + // place on the point's line, then turned a quarter clockwise about + // that start. + let paint = marks_paint(style, measured, family, run_paint)?; + let shift = letter_spacing(style) / 2.0 + center_x; + let origin = (across, first - shift); + let length = last - first + measured.size; + Ok(vec![ + DisplayCommand::PushState, + DisplayCommand::Transform { + origin: ReaderPoint { + x: display_number(origin.0), + y: display_number(origin.1), + }, + box_size: ReaderSize { + width: display_number(length), + height: display_number(measured.size), + }, + transforms: vec![ReaderTransform::Rotate { + radians: std::f64::consts::FRAC_PI_2, + }], + }, + marks_command( + &mark, + places + .iter() + .map(|place| (origin.0 + (place - shift - origin.1), origin.1)) + .collect(), + display_rect( + origin.0, + origin.1 - CANVAS_TOP_ASCENT_RATIO * measured.size, + length, + measured.size, + ), + paint, + ), + DisplayCommand::PopState, + ]) +} + +/// One mark per origin, painted as annotation text. +fn marks_command( + mark: &str, + origins: Vec<(f64, f64)>, + rect: crate::render::contract::ReaderRect, + paint: RunPaint, +) -> DisplayCommand { + let text = mark.repeat(origins.len()); + let clusters = origins + .into_iter() + .enumerate() + .map(|(index, (x, y))| crate::render::TextCluster::at((index * mark.len()) as u32, x, y)) + .collect(); + DisplayCommand::PaintRuby(DisplayTextCommand { + text: text.into(), + rect, + paint, + line_height_px: None, + href: None, + source_text: None, + source_text_offset: None, + clusters, + }) +} + +/// The mark's first character: the glyph a mark paints. +fn first_char(mark: &str) -> String { + mark.chars().next().map(String::from).unwrap_or_default() +} + +/// The style's letter spacing in pixels, as its runs paint it. +fn letter_spacing(style: &InlineFormattingStyle) -> f64 { + match style.text_flow.letter_spacing { + LengthPercentage::Length(length) => f64::from(length.get()), + _ => 0.0, + } +} + +#[cfg(test)] +mod tests { + use super::mark_places; + + #[test] + fn a_mark_centres_on_each_grapheme_that_takes_one() { + // 甲 and 乙 take marks; the ideographic comma and the space do not. + let text = "甲、乙 "; + let clusters = [(0, 0.0), (3, 16.0), (6, 32.0), (9, 48.0)]; + assert_eq!(mark_places(text, &clusters, 52.0), [8.0, 40.0]); + } + + #[test] + fn a_cluster_of_several_graphemes_shares_its_advance() { + let text = "ab"; + assert_eq!(mark_places(text, &[(0, 10.0)], 30.0), [15.0, 25.0]); + } +} diff --git a/crates/rito-core/src/fragment_paint/family.rs b/crates/rito-core/src/fragment_paint/family.rs index 7eff556da..661f31567 100644 --- a/crates/rito-core/src/fragment_paint/family.rs +++ b/crates/rito-core/src/fragment_paint/family.rs @@ -36,6 +36,23 @@ pub(super) fn face_family( } } +/// The family a face paints by in its vertical forms: the same bytes the +/// host registers again under this name with the OpenType `vert` feature +/// on, which a vertical line's upright glyphs are shaped with. +pub(crate) fn vertical_forms_family(family: &str) -> String { + format!("__RitoVert_{family}") +} + +/// The family a face paints a combined upright string by in its half-, +/// third- or quarter-width forms: the same bytes the host registers again +/// under this name with that OpenType feature on. +pub(crate) fn combined_forms_family(forms: rito_fragment::CombinedForms, family: &str) -> String { + match forms.feature() { + Some(feature) => format!("__Rito{}_{family}", feature.to_ascii_uppercase()), + None => family.to_owned(), + } +} + /// The runs of consecutive clusters drawn from one face, in order. A run /// without clusters is one piece of the default face. pub(super) fn face_pieces(faces: &[FontFaceId]) -> Vec<(Range, FontFaceId)> { diff --git a/crates/rito-core/src/fragment_paint/flow_text.rs b/crates/rito-core/src/fragment_paint/flow_text.rs new file mode 100644 index 000000000..4fd59c7d6 --- /dev/null +++ b/crates/rito-core/src/fragment_paint/flow_text.rs @@ -0,0 +1,48 @@ +//! Each inline flow's concatenated item text, which its text fragments +//! address by byte range, built once per paint however many of the flow's +//! lines the paint draws: a paragraph of thousands of lines is joined once, +//! not once a line. + +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; + +use rito_fragment::{FormattingNodeId, InlineItem}; +use rustc_hash::FxHashMap; + +/// One flow's item text and where each text item sits in it. +pub(super) struct FlowText { + /// The text items' text in order, shared by every run cut from it. + pub(super) text: Arc, + /// Each text item's byte range in `text`, with its item index. + pub(super) item_ranges: Vec<(Range, usize)>, +} + +/// The flow texts one paint has joined, by inline-flow node. +#[derive(Default)] +pub(super) struct FlowTexts(RefCell>>); + +impl FlowTexts { + /// The text of the flow `flow`, whose items are `items`. + pub(super) fn of(&self, flow: FormattingNodeId, items: &[InlineItem]) -> Rc { + if let Some(text) = self.0.borrow().get(&flow.0) { + return Rc::clone(text); + } + let mut text = String::new(); + let mut item_ranges = Vec::new(); + for (item_index, item) in items.iter().enumerate() { + if let InlineItem::Text { + text: item_text, .. + } = item + { + let start = text.len(); + text.push_str(item_text); + item_ranges.push((start..text.len(), item_index)); + } + } + let joined = Rc::new(FlowText { + text: text.into(), + item_ranges, + }); + self.0.borrow_mut().insert(flow.0, Rc::clone(&joined)); + joined + } +} diff --git a/crates/rito-core/src/fragment_paint/image.rs b/crates/rito-core/src/fragment_paint/image.rs index 76d496542..b52debe5a 100644 --- a/crates/rito-core/src/fragment_paint/image.rs +++ b/crates/rito-core/src/fragment_paint/image.rs @@ -72,41 +72,22 @@ pub(super) fn append_image_command( // is untouched — this is a paint-rect adjustment. let mut draw = image.rect; if !*fit_contain { - // Blink pixel-snaps a plain replaced image's paint rect to whole - // CSS pixels (probed: an at x=22.25 rasters at 22, at 22.5 - // at 23, bit-identical to a canvas draw at the same integers). - // SVG-folded content is NOT snapped: it paints through the svg's - // own transform, and the reference renders it at the fractional - // position. - let left = snap_css(line_x + draw.x); - let top = snap_css(line_y + draw.y); - let right = snap_css(line_x + draw.x + draw.width); - let bottom = snap_css(line_y + draw.y + draw.height); - draw = rito_fragment::FragmentRect { - x: left - line_x, - y: top - line_y, - width: right - left, - height: bottom - top, - }; // Computed `object-fit: contain` (the UA stylesheet's reading- // system default, see rito-stylo's ua.rs): the raster letterboxes - // inside the snapped box, exposing the page ground in the gap — + // inside the layout box, exposing the page ground in the gap — // no clamp-bleed slivers, those model SVG viewBox clamp // addressing. The box itself, its border and its background keep - // the author's rect. Guard band, judged on the UNSNAPPED layout - // box (the snap itself shifts a small box's ratio by up to a - // pixel per axis): an auto-sized box differs from the raster - // ratio only by LayoutUnit quantization dust, so skipping those - // keeps every ratio-true image bit-identical to the plain fill - // it always painted (the pixel-walk zero books stay zero). Only - // a box the author forced off the raster ratio letterboxes. + // the author's rect. Guard band: an auto-sized box differs from + // the raster ratio only by LayoutUnit quantization dust, so + // skipping those keeps every ratio-true image bit-identical to the + // plain fill it always painted (the pixel-walk zero books stay + // zero). Only a box the author forced off the raster ratio + // letterboxes. if *object_fit == rito_style_contract::ObjectFit::Contain && *intrinsic_width > 0.0 && *intrinsic_height > 0.0 && image.rect.width > 0.0 && image.rect.height > 0.0 - && draw.width > 0.0 - && draw.height > 0.0 { let box_ratio = image.rect.width / image.rect.height; let raster_ratio = intrinsic_width / intrinsic_height; @@ -123,6 +104,29 @@ pub(super) fn append_image_command( }; } } + // Blink pixel-snaps a plain replaced image's paint rect to whole + // CSS pixels (probed: an at x=22.25 rasters at 22, at 22.5 + // at 23, bit-identical to a canvas draw at the same integers), + // the rect its `object-fit` placed the raster in (measured: a + // 723x1 rule in a 626.69x1.69 box at y 126.31 contains to + // [126.72, 127.59) and rasters on the one row [127, 128)). + // SVG-folded content is NOT snapped: it paints through the svg's + // own transform, and the reference renders it at the fractional + // position. + let (x, width) = snap_span(line_x + draw.x, draw.width); + let (y, height) = snap_span(line_y + draw.y, draw.height); + // A raster whose snapped rect is empty paints nothing (Blink's + // `ImagePainter::PaintIntoRect` returns before drawing). Its box, + // border and background above still paint. + if width <= 0.0 || height <= 0.0 { + return Ok(()); + } + draw = rito_fragment::FragmentRect { + x: x - line_x, + y: y - line_y, + width, + height, + }; } if *fit_contain && *intrinsic_width > 0.0 && *intrinsic_height > 0.0 { let contain = |outer: rito_fragment::FragmentRect, ratio_w: f64, ratio_h: f64| { @@ -216,3 +220,18 @@ pub(super) fn append_image_command( )); Ok(()) } + +/// A span starting at `start` for `size` CSS px, snapped as Blink's +/// `SnapSizeToPixel` snaps a paint rect: the start rounds to a whole +/// pixel and the size to the whole pixels its end rounds to, but a span +/// longer than four LayoutUnits keeps at least one pixel (measured: a +/// 723x1 rule contained under a pixel tall, both ends rounding to 586, +/// rasters on row 586, where rounding both ends would leave it nothing). +fn snap_span(start: f64, size: f64) -> (f64, f64) { + let snapped_start = snap_css(start); + let snapped_size = snap_css(start + size) - snapped_start; + if snapped_size == 0.0 && size > 4.0 / 64.0 { + return (snapped_start, 1.0); + } + (snapped_start, snapped_size) +} diff --git a/crates/rito-core/src/fragment_paint/line.rs b/crates/rito-core/src/fragment_paint/line.rs index 504c1820b..d37b46f43 100644 --- a/crates/rito-core/src/fragment_paint/line.rs +++ b/crates/rito-core/src/fragment_paint/line.rs @@ -16,8 +16,10 @@ use crate::render::contract::{ use crate::render::{display_rect, DisplayCommand}; use super::family::css_color; +use super::flow_text::FlowTexts; use super::image::append_image_command; use super::text_run::{append_text_run_command, ItemLineExtent, LinePaints}; +use super::LineAnnotations; #[allow(clippy::too_many_arguments)] pub(super) fn append_line_commands( @@ -29,9 +31,10 @@ pub(super) fn append_line_commands( faces: Option<&[std::sync::Arc]>, image_border_paints: Option<&BTreeMap>, flow_item_sources: Option<&BTreeMap>>, - ruby_annotation_runs: Option<&BTreeMap<(u32, usize), rito_inline::MeasuredRuby>>, + annotations: LineAnnotations<'_>, snap_origin_y: f64, ratio: f64, + flow_texts: &FlowTexts, ) -> EpubResult<()> { let FormattingNodeContent::InlineFlow { items } = &tree.node(line.source).content else { return Err(EpubError::new("line fragment source is not an inline flow")); @@ -42,19 +45,10 @@ pub(super) fn append_line_commands( .styles() .ok_or_else(|| EpubError::new("formatting tree carries no style tables"))?; // Text fragments address the flow's concatenated item text by byte - // range; rebuild that concatenation to slice run text and map each run - // back to the item whose style paints it. - let mut full_text = String::new(); - let mut text_ranges: Vec<(std::ops::Range, usize)> = Vec::new(); - for (item_index, item) in items.iter().enumerate() { - if let InlineItem::Text { text, .. } = item { - let start = full_text.len(); - full_text.push_str(text); - text_ranges.push((start..full_text.len(), item_index)); - } - } - // Shared by every run cut from it. - let full_text: std::sync::Arc = full_text.into(); + // range: it slices run text and maps each run back to the item whose + // style paints it. + let flow_text = flow_texts.of(line.source, items); + let (full_text, text_ranges) = (&flow_text.text, flow_text.item_ranges.as_slice()); let line_x = origin_x + line.rect.x; let line_y = origin_y + line.rect.y; // The list item's outside disc marker, filled with the line's text @@ -119,8 +113,8 @@ pub(super) fn append_line_commands( &mut paints, items, styles, - &full_text, - &text_ranges, + full_text, + text_ranges, &item_extents, line, run, @@ -128,7 +122,7 @@ pub(super) fn append_line_commands( line_y, faces, item_sources, - ruby_annotation_runs, + annotations, snap_origin_y, ratio, )?; @@ -163,9 +157,10 @@ pub(super) fn append_line_commands( faces, image_border_paints, flow_item_sources, - ruby_annotation_runs, + annotations, snap_origin_y, ratio, + flow_texts, )?; } } diff --git a/crates/rito-core/src/fragment_paint/tests.rs b/crates/rito-core/src/fragment_paint/tests.rs index 84cd6b45c..48abd9fda 100644 --- a/crates/rito-core/src/fragment_paint/tests.rs +++ b/crates/rito-core/src/fragment_paint/tests.rs @@ -110,6 +110,7 @@ fn text_run(x: f64, width: f64, start: u32, end: u32) -> Fragment { clusters: Vec::new().into(), cluster_grid: false, segments: None, + orientations: None, }) } @@ -133,10 +134,15 @@ fn boxed_line(children: Vec) -> Fragment { }, baseline: 13.0, trailing_whitespace: 0.0, - ruby_growth: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, text_runs: Vec::new(), children, })], + cuts: rito_fragment::BlockCuts::NONE, }) } diff --git a/crates/rito-core/src/fragment_paint/tests/boxes.rs b/crates/rito-core/src/fragment_paint/tests/boxes.rs index 2968163ef..5dfe786cc 100644 --- a/crates/rito-core/src/fragment_paint/tests/boxes.rs +++ b/crates/rito-core/src/fragment_paint/tests/boxes.rs @@ -31,7 +31,7 @@ fn a_transformed_box_wraps_its_subtree_in_a_transform_state() { faces: None, node_paints: Some(&node_paints), list_markers: None, - ruby_annotation_runs: None, + annotations: crate::fragment_paint::LineAnnotations::default(), vertical_frame: None, flow_item_sources: None, ratio: 1.0, @@ -100,18 +100,21 @@ fn an_outside_marker_paints_its_measured_box_ending_at_the_content_edge() { x: 0.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 1, x: 8.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 2, x: 12.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, ], grid: false, @@ -127,7 +130,7 @@ fn an_outside_marker_paints_its_measured_box_ending_at_the_content_edge() { 0.0, FragmentPaintContext { list_markers: Some(&markers), - ruby_annotation_runs: None, + annotations: crate::fragment_paint::LineAnnotations::default(), ..FragmentPaintContext::default() }, ) @@ -166,3 +169,83 @@ fn an_outside_marker_paints_its_measured_box_ending_at_the_content_edge() { "the item's own text keeps its inline box" ); } + +/// A fragment a page break cut strokes no border on the cut edge: the +/// box's next fragment opens with no top border, its earlier one ends +/// with no bottom border, and the sides run on. +#[test] +fn a_cut_edge_strokes_no_border() { + use crate::render::contract::{ + ReaderBlockBorder, ReaderBorderBox, ReaderBorderEdgePaint, ReaderColor, + }; + let fixture = two_color_flow(|red, _| vec![text_item("box", red, 0.0)]); + let edge = Some(ReaderBorderEdgePaint { + color: ReaderColor::srgb8(0, 0, 0, 1.0), + style: ReaderBorderStyle::Solid, + }); + let mut node_paints = BTreeMap::new(); + node_paints.insert( + 0, + NodePaint::Box { + paint: ReaderBlockPaint { + border: Some(ReaderBlockBorder { + top: edge, + right: edge, + bottom: edge, + left: edge, + }), + ..ReaderBlockPaint::default() + }, + border_box: Some(ReaderBorderBox { + top_width: 1.0, + right_width: 1.0, + bottom_width: 1.0, + left_width: 1.0, + }), + transform: None, + bevels: Vec::new(), + segment_horizontal_edges: false, + }, + ); + let painted = |cuts: rito_fragment::BlockCuts| { + let mut root = boxed_line(vec![text_run(0.0, 30.0, 0, 3)]); + if let Fragment::Box(fragment) = &mut root { + fragment.cuts = cuts; + } + let mut commands = Vec::new(); + append_fragment_display_commands( + &mut commands, + &fixture.tree, + &root, + 0.0, + 0.0, + FragmentPaintContext { + node_paints: Some(&node_paints), + ..FragmentPaintContext::default() + }, + ) + .expect("fragments paint"); + commands + .into_iter() + .find_map(|command| match command { + DisplayCommand::PaintBlock { + paint, border_box, .. + } => Some((paint.border.expect("border"), border_box.expect("widths"))), + _ => None, + }) + .expect("the box paints") + }; + let (border, widths) = painted(rito_fragment::BlockCuts { + start: true, + end: false, + }); + assert!(border.top.is_none() && widths.top_width == 0.0); + assert!(border.bottom.is_some() && widths.bottom_width == 1.0); + assert!(border.left.is_some() && border.right.is_some()); + let (border, widths) = painted(rito_fragment::BlockCuts { + start: false, + end: true, + }); + assert!(border.top.is_some() && widths.top_width == 1.0); + assert!(border.bottom.is_none() && widths.bottom_width == 0.0); +} diff --git a/crates/rito-core/src/fragment_paint/tests/faces.rs b/crates/rito-core/src/fragment_paint/tests/faces.rs index 4d289a8e5..983d8792d 100644 --- a/crates/rito-core/src/fragment_paint/tests/faces.rs +++ b/crates/rito-core/src/fragment_paint/tests/faces.rs @@ -35,6 +35,7 @@ fn run_with_faces(width: f64, clusters: &[(u32, f64, u32)]) -> Fragment { x: *x, face: FontFaceId(*face), joins: false, + continues_ligature: false, }) .collect(); } @@ -116,3 +117,35 @@ fn a_face_the_engine_never_registered_fails_closed() { ); assert!(result.is_err()); } + +/// A ligature paints as one cluster from its start: the cluster that +/// continues it leaves the painted run, so a pen drawing each cluster +/// draws the whole ligature where a pen drawing strings forms it. +#[test] +fn a_ligature_paints_as_one_cluster() { + let fixture = two_color_flow(|red, _| vec![text_item("fix", red, 0.0)]); + let mut run = run_with_faces(24.0, &[(0, 0.0, 1), (1, 4.0, 1), (2, 8.0, 1)]); + if let Fragment::Text(text) = &mut run { + let mut clusters = text.clusters.to_vec(); + clusters[1].joins = true; + clusters[1].continues_ligature = true; + clusters[2].joins = true; + text.clusters = clusters.into(); + } + let root = boxed_line(vec![run]); + let faces = ["__RitoPinned_serif".into(), "__RitoPinned_cjk".into()]; + let commands = paint_with_faces(&root, &fixture.tree, &faces); + let clusters: Vec<(u32, bool)> = commands + .iter() + .filter_map(|command| match command { + DisplayCommand::PaintText(text) => Some(text), + _ => None, + }) + .flat_map(|text| { + text.clusters + .iter() + .map(|cluster| (cluster.byte, cluster.joins)) + }) + .collect(); + assert_eq!(clusters, vec![(0, false), (2, true)]); +} diff --git a/crates/rito-core/src/fragment_paint/tests/images.rs b/crates/rito-core/src/fragment_paint/tests/images.rs index 1e6cdf724..c544737bb 100644 --- a/crates/rito-core/src/fragment_paint/tests/images.rs +++ b/crates/rito-core/src/fragment_paint/tests/images.rs @@ -7,11 +7,49 @@ fn painted_image_rect( intrinsic_height: f64, object_fit: rito_style_contract::ObjectFit, ) -> ReaderRect { + painted_image_rect_in( + intrinsic_width, + intrinsic_height, + object_fit, + FragmentRect { + x: 5.0, + y: 2.0, + width: 40.0, + height: 30.0, + }, + ) + .expect("the image paints") +} + +fn painted_image_rect_in( + intrinsic_width: f64, + intrinsic_height: f64, + object_fit: rito_style_contract::ObjectFit, + image_rect: FragmentRect, +) -> Option { + painted_image_rect_framed( + intrinsic_width, + intrinsic_height, + object_fit, + image_rect, + None, + ) +} + +/// The rect an image at `image_rect` on a line paints at, the tree laid +/// out in `vertical_frame` when one is given. +fn painted_image_rect_framed( + intrinsic_width: f64, + intrinsic_height: f64, + object_fit: rito_style_contract::ObjectFit, + image_rect: FragmentRect, + vertical_frame: Option, +) -> Option { use rito_style_contract::{ AlignItems, BoxSizing, Clear, CssPx, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, NonNegativeLengthPercentage, - Overflow, PageBreak, PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, NonNegativeLengthPercentage, Overflow, + PageBreak, PhysicalSides, Position, PreferredSize, }; let mut inline = InlineStyleTable::new(1); let text_style = inline @@ -43,6 +81,9 @@ fn painted_image_rect( bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -51,8 +92,11 @@ fn painted_image_rect( width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -82,7 +126,7 @@ fn painted_image_rect( fit_contain: false, viewport: None, baseline_shift_px: 0.0, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, object_fit, }], }, @@ -96,21 +140,33 @@ fn painted_image_rect( .expect("tree builds"); let root = boxed_line(vec![Fragment::Image(ImageFragment { source: FormattingNodeId(0), - rect: FragmentRect { - x: 5.0, - y: 2.0, - width: 40.0, - height: 30.0, - }, + rect: image_rect, item_index: 0, })]); - let commands = paint(&tree, &root); - assert_eq!(commands.len(), 1); - let DisplayCommand::PaintImage { src, rect, .. } = &commands[0] else { - panic!("expected an image command, got {:?}", commands[0]); - }; - assert_eq!(src, "images/portrait.png"); - *rect + let mut commands = Vec::new(); + append_fragment_display_commands( + &mut commands, + &tree, + &root, + 0.0, + 0.0, + FragmentPaintContext { + vertical_frame, + ..FragmentPaintContext::default() + }, + ) + .expect("fragments paint"); + assert!( + commands.len() <= 1, + "expected at most one command: {commands:?}" + ); + commands.first().map(|command| { + let DisplayCommand::PaintImage { src, rect, .. } = command else { + panic!("expected an image command, got {command:?}"); + }; + assert_eq!(src, "images/portrait.png"); + *rect + }) } #[test] @@ -137,10 +193,116 @@ fn a_ratio_true_box_paints_identically_under_object_fit_contain() { fn an_author_box_off_the_raster_ratio_letterboxes_under_contain() { use rito_style_contract::ObjectFit; // A portrait 30x40 raster inside the landscape 40x30 box scales - // by 0.75 to 22.5x30, centered on the inline axis; the box (and - // its border and background) keeps the author's rect. + // by 0.75 to 22.5x30, centered on the inline axis at 27.75, and the + // placed rect snaps to whole pixels; the box (and its border and + // background) keeps the author's rect. assert_eq!( painted_image_rect(30.0, 40.0, ObjectFit::Contain), - display_rect(27.75, 28.0, 22.5, 30.0) + display_rect(28.0, 28.0, 22.0, 30.0) + ); +} + +#[test] +fn a_letterboxed_raster_snaps_where_contain_placed_it() { + use rito_style_contract::ObjectFit; + // A 723x1 rule declared 538395x1430 and scaled into a 626.6875 x + // 1.6875 box at y 126.3125: contain places it on [126.72, 127.59), + // which snaps to the one row [127, 128) the browser rasters it on + // (snapping the box first left a 2px box and a half-pixel raster + // smeared over two rows). + assert_eq!( + painted_image_rect_in( + 723.0, + 1.0, + ObjectFit::Contain, + FragmentRect { + x: 5.0, + y: 100.3125, + width: 626.6875, + height: 1.6875, + }, + ), + Some(display_rect(19.0, 127.0, 627.0, 1.0)) + ); +} + +#[test] +fn a_raster_contained_within_one_row_keeps_that_row() { + use rito_style_contract::ObjectFit; + // The same rule at y 126.125: contain places it on [126.54, 127.40), + // whose edges both round to 127. The browser keeps a span longer than + // four LayoutUnits at least one pixel, so the rule rasters on the row + // its start rounds to instead of vanishing. + assert_eq!( + painted_image_rect_in( + 723.0, + 1.0, + ObjectFit::Contain, + FragmentRect { + x: 5.0, + y: 100.125, + width: 626.6875, + height: 1.6875, + }, + ), + Some(display_rect(19.0, 127.0, 627.0, 1.0)) + ); +} + +#[test] +fn a_raster_contained_to_a_sliver_paints_nothing() { + use rito_style_contract::ObjectFit; + // Contained to 1/32 px tall, the rule spans under four LayoutUnits: + // its snapped rect is empty, and the browser draws nothing. + assert_eq!( + painted_image_rect_in( + 1000.0, + 1.0, + ObjectFit::Contain, + FragmentRect { + x: 5.0, + y: 100.0, + width: 31.25, + height: 4.0, + }, + ), + None + ); +} + +#[test] +fn a_column_image_snaps_like_any_inline_image() { + use rito_style_contract::ObjectFit; + // Down a column the image's box comes back to physical terms and + // paints as any inline image does, snapped to whole CSS pixels (the + // browser rasters a plate centred at a .5 offset on the next row; the + // raw rect smeared it across two). The column of the boxed line stands + // at x 174, y 34: the image's box lands at (163.75, 44.5). + let frame = crate::fragment_paint::VerticalFrame { + converter: rito_fragment::WritingModeConverter::from_logical_size( + rito_style_contract::WritingDirection { + mode: rito_style_contract::WritingMode::VerticalRightToLeft, + direction: rito_style_contract::Direction::LeftToRight, + }, + 100.0, + 220.0, + ), + left: 0.0, + top: 20.0, + }; + assert_eq!( + painted_image_rect_framed( + 30.0, + 40.0, + ObjectFit::Fill, + FragmentRect { + x: 10.5, + y: 0.25, + width: 40.0, + height: 30.0, + }, + Some(frame), + ), + Some(display_rect(164.0, 45.0, 30.0, 40.0)) ); } diff --git a/crates/rito-core/src/fragment_paint/tests/ruby.rs b/crates/rito-core/src/fragment_paint/tests/ruby.rs index 495b838fd..fa4f87660 100644 --- a/crates/rito-core/src/fragment_paint/tests/ruby.rs +++ b/crates/rito-core/src/fragment_paint/tests/ruby.rs @@ -13,6 +13,7 @@ fn ruby_bases_paint_their_annotation_above_the_run() { text: "かんじ".to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }] }); @@ -31,24 +32,28 @@ fn ruby_bases_paint_their_annotation_above_the_run() { x: 0.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 3, x: 8.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 6, x: 16.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, ], grid: false, }, over_offset: 16.0, em_ascent: 7.046875, + orientations: None, }, ); let mut commands = Vec::new(); @@ -59,7 +64,10 @@ fn ruby_bases_paint_their_annotation_above_the_run() { 0.0, 0.0, FragmentPaintContext { - ruby_annotation_runs: Some(&ruby_runs), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&ruby_runs), + emphasis: None, + }, ..FragmentPaintContext::default() }, ) @@ -123,6 +131,7 @@ fn a_ruby_annotation_distributes_over_the_column_extent_on_the_layout_grid() { text: "かん".to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }] }); @@ -142,18 +151,21 @@ fn a_ruby_annotation_distributes_over_the_column_extent_on_the_layout_grid() { x: 0.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 3, x: 8.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, ], grid: false, }, over_offset: 16.0, em_ascent: 7.046875, + orientations: None, }, ); let mut commands = Vec::new(); @@ -164,7 +176,10 @@ fn a_ruby_annotation_distributes_over_the_column_extent_on_the_layout_grid() { 0.0, 0.0, FragmentPaintContext { - ruby_annotation_runs: Some(&ruby_runs), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&ruby_runs), + emphasis: None, + }, ..FragmentPaintContext::default() }, ) @@ -199,6 +214,7 @@ fn a_base_shaped_into_several_runs_on_one_line_carries_one_annotation_over_all_o text: "かんじ".to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }] }); @@ -215,24 +231,28 @@ fn a_base_shaped_into_several_runs_on_one_line_carries_one_annotation_over_all_o x: 0.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 3, x: 8.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 6, x: 16.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, ], grid: false, }, over_offset: 16.0, em_ascent: 7.046875, + orientations: None, }, ); let mut commands = Vec::new(); @@ -243,7 +263,10 @@ fn a_base_shaped_into_several_runs_on_one_line_carries_one_annotation_over_all_o 0.0, 0.0, FragmentPaintContext { - ruby_annotation_runs: Some(&ruby_runs), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&ruby_runs), + emphasis: None, + }, ..FragmentPaintContext::default() }, ) diff --git a/crates/rito-core/src/fragment_paint/tests/rules.rs b/crates/rito-core/src/fragment_paint/tests/rules.rs index 871c0c096..4ae9b95d5 100644 --- a/crates/rito-core/src/fragment_paint/tests/rules.rs +++ b/crates/rito-core/src/fragment_paint/tests/rules.rs @@ -14,6 +14,7 @@ fn rule_paints_stroke_across_its_sized_box() { height: 2.0, }, children: Vec::new(), + cuts: rito_fragment::BlockCuts::NONE, }); let root = Fragment::Box(BoxFragment { source: FormattingNodeId(0), @@ -24,6 +25,7 @@ fn rule_paints_stroke_across_its_sized_box() { height: 30.0, }, children: vec![rule], + cuts: rito_fragment::BlockCuts::NONE, }); let mut paints = std::collections::BTreeMap::new(); paints.insert( @@ -46,7 +48,7 @@ fn rule_paints_stroke_across_its_sized_box() { faces: None, node_paints: Some(&paints), list_markers: None, - ruby_annotation_runs: None, + annotations: crate::fragment_paint::LineAnnotations::default(), vertical_frame: None, flow_item_sources: None, ratio: 1.0, @@ -79,6 +81,7 @@ fn an_inset_rule_paints_as_a_two_tone_border_box() { height: 2.0, }, children: Vec::new(), + cuts: rito_fragment::BlockCuts::NONE, }); let root = Fragment::Box(BoxFragment { source: FormattingNodeId(0), @@ -89,6 +92,7 @@ fn an_inset_rule_paints_as_a_two_tone_border_box() { height: 30.0, }, children: vec![rule], + cuts: rito_fragment::BlockCuts::NONE, }); let mut paints = std::collections::BTreeMap::new(); paints.insert( diff --git a/crates/rito-core/src/fragment_paint/tests/text_runs.rs b/crates/rito-core/src/fragment_paint/tests/text_runs.rs index 517c66eb4..31dc9c74d 100644 --- a/crates/rito-core/src/fragment_paint/tests/text_runs.rs +++ b/crates/rito-core/src/fragment_paint/tests/text_runs.rs @@ -103,10 +103,15 @@ fn a_baseline_rounds_the_line_top_on_css_pixels_and_the_sum_on_the_device_grid() }, baseline: within_line, trailing_whitespace: 0.0, - ruby_growth: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, text_runs: Vec::new(), children: vec![text_run(0.0, 8.0, 0, 1)], })], + cuts: rito_fragment::BlockCuts::NONE, }); let mut commands = Vec::new(); append_fragment_display_commands( diff --git a/crates/rito-core/src/fragment_paint/tests/vertical_columns.rs b/crates/rito-core/src/fragment_paint/tests/vertical_columns.rs index e1d55b9ad..cd0d34ede 100644 --- a/crates/rito-core/src/fragment_paint/tests/vertical_columns.rs +++ b/crates/rito-core/src/fragment_paint/tests/vertical_columns.rs @@ -2,6 +2,44 @@ use super::*; +/// A text fragment of a vertical line as layout leaves it: a cluster per +/// character of `text` (the flow's bytes `start..end`) every `step` down +/// the line, upright with a 16px glyph's half advance and vertical origin +/// centred half a pixel over the run's baseline, unless Latin, which lies +/// sideways on a baseline 5px under it. +fn column_run(x: f64, width: f64, start: u32, text: &str, step: f64) -> Fragment { + let Fragment::Text(mut run) = text_run(x, width, start, start + text.len() as u32) else { + unreachable!("a text run is text"); + }; + run.clusters = text + .char_indices() + .enumerate() + .map(|(index, (byte, _))| rito_fragment::ClusterPosition { + byte: start + byte as u32, + x: index as f64 * step, + face: rito_fragment::FontFaceId::default(), + joins: false, + continues_ligature: false, + }) + .collect(); + run.orientations = Some( + text.chars() + .map(|glyph| { + if glyph.is_ascii_alphabetic() { + rito_fragment::ClusterOrientation::Sideways { baseline: 5.0 } + } else { + rito_fragment::ClusterOrientation::Upright { + center: -0.5, + half_advance: 8.0, + vertical_origin: 14.08, + } + } + }) + .collect(), + ); + Fragment::Text(run) +} + /// A vertical-rl page fragment for the paint-parity instrument: one /// column of ideographs with rotated brackets and corner-shifted /// marks, a rubied base, and a second column of leaders, a dash and @@ -31,6 +69,7 @@ fn vertical_column_commands() -> Vec { text: "かんじ".to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }, text_item("…—ab", spaced, 0.0), @@ -60,7 +99,11 @@ fn vertical_column_commands() -> Vec { }, baseline: 13.0, trailing_whitespace: 0.0, - ruby_growth: growth, + annotation_growth: growth, + annotation_overflow: growth, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, text_runs: Vec::new(), children: runs, }) @@ -79,10 +122,20 @@ fn vertical_column_commands() -> Vec { 24.0, 8.0, 160.0, - vec![text_run(0.0, 128.0, 0, 24), text_run(128.0, 32.0, 24, 30)], + vec![ + column_run(0.0, 128.0, 0, "「春日」、剧场。", 16.0), + column_run(128.0, 32.0, 24, "漢字", 16.0), + ], + ), + line( + 32.0, + 20.0, + 0.0, + 72.0, + vec![column_run(0.0, 72.0, 30, "…—ab", 18.0)], ), - line(32.0, 20.0, 0.0, 72.0, vec![text_run(0.0, 72.0, 30, 38)]), ], + cuts: rito_fragment::BlockCuts::NONE, }); // The annotation measured when the chapter was built: the // style's primary face is Tinos, whose 8px typo ascent puts the @@ -101,24 +154,38 @@ fn vertical_column_commands() -> Vec { x: 0.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 3, x: 8.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, rito_fragment::ClusterPosition { byte: 6, x: 16.0, face: rito_fragment::FontFaceId::default(), joins: false, + continues_ligature: false, }, ], grid: false, }, over_offset: 16.0, em_ascent: 6.09375, + orientations: Some( + vec![ + rito_fragment::ClusterOrientation::Upright { + center: -0.5, + half_advance: 4.0, + vertical_origin: 7.04, + }; + 3 + ] + .into(), + ), }, ); let mut commands = Vec::new(); @@ -129,8 +196,23 @@ fn vertical_column_commands() -> Vec { 0.0, 0.0, FragmentPaintContext { - vertical_frame: Some((220.0, 20.0)), - ruby_annotation_runs: Some(&ruby_runs), + // A content box 220 wide whose top-left corner is (0, 20). + vertical_frame: Some(crate::fragment_paint::VerticalFrame { + converter: rito_fragment::WritingModeConverter::from_logical_size( + rito_style_contract::WritingDirection { + mode: rito_style_contract::WritingMode::VerticalRightToLeft, + direction: rito_style_contract::Direction::LeftToRight, + }, + root.rect().width, + 220.0, + ), + left: 0.0, + top: 20.0, + }), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&ruby_runs), + emphasis: None, + }, ..FragmentPaintContext::default() }, ) @@ -165,11 +247,13 @@ fn write_vertical_paint_parity_fixture() { .expect("fixture writes"); } -/// The vertical column paints glyph by glyph: upright glyphs share a -/// run with an origin each one step down the column, corner marks -/// shift into the em's top-right, rotated marks paint as their own -/// run under a quarter-turn transform, and the annotation spreads -/// its glyphs down the base span. +/// The vertical column paints each cluster where layout put it: upright +/// glyphs half their advance left of their centre line (12.5px in from the +/// line-over edge: the 13px baseline less half a pixel) and their vertical +/// origin below their place, corner marks in the em's +/// top-right, rotated marks alone under a quarter turn about their em +/// centre, Latin set sideways under a quarter turn about its start on its +/// alphabetic baseline, and the annotation spread down the base span. #[test] fn a_vertical_column_places_every_glyph_and_rotates_its_marks() { let commands = vertical_column_commands(); @@ -190,51 +274,53 @@ fn a_vertical_column_places_every_glyph_and_rotates_its_marks() { ); } }; - // 「 | 春日 | 」 | 、剧场。 | 漢字 | … | — | ab - assert_eq!(texts.len(), 8, "{texts:#?}"); - let transforms = commands + let transform_origins: Vec<(f64, f64)> = commands .iter() - .filter(|command| matches!(command, DisplayCommand::Transform { .. })) - .count(); - assert_eq!(transforms, 4, "one quarter turn per rotated mark"); + .filter_map(|command| match command { + DisplayCommand::Transform { + origin, transforms, .. + } => { + assert_eq!( + transforms, + &vec![ReaderTransform::Rotate { + radians: std::f64::consts::FRAC_PI_2 + }] + ); + Some((origin.x, origin.y)) + } + _ => None, + }) + .collect(); + // 「春日」、剧场。 | 漢字 | …— | ab + assert_eq!(texts.len(), 4, "{texts:#?}"); assert!(texts.iter().all(|text| !text.clusters.is_empty())); - // The first column's glyphs sit at x 196 (frame right 220 minus - // the 24px line thickness), stepping 16px from the column top 20 - // with baselines 0.8 em below each glyph top. - assert_eq!(texts[0].text, "「"); - origins(&texts[0].clusters, &[(0, 196.0, 32.8)]); - let DisplayCommand::Transform { - origin, transforms, .. - } = &commands[1] - else { - panic!( - "the rotated mark paints under a transform, got {:?}", - commands[1] - ); - }; - assert_eq!((origin.x, origin.y), (204.0, 28.0)); - assert_eq!( - transforms, - &vec![ReaderTransform::Rotate { - radians: std::f64::consts::FRAC_PI_2 - }] + // The first column's line-over edge is the frame's right edge, 220: + // its glyphs centre at 207.5, their origins at 199.5, each 16px down + // from the column top 20 and its vertical origin below that, the + // punctuation in its vertical forms like the rest. + assert_eq!(texts[0].text, "「春日」、剧场。"); + origins( + &texts[0].clusters, + &(0..8) + .map(|index| (3 * index, 199.5, 34.08 + 16.0 * f64::from(index))) + .collect::>(), ); - assert_eq!(texts[1].text, "春日"); - origins(&texts[1].clusters, &[(0, 196.0, 48.8), (3, 196.0, 64.8)]); - assert_eq!(texts[3].text, "、剧场。"); - // 、 and 。 ride the em's top-right corner; 剧场 stay upright. + assert_eq!(texts[1].text, "漢字"); origins( - &texts[3].clusters, - &[ - (0, 204.0, 87.2), - (3, 196.0, 112.8), - (6, 196.0, 128.8), - (9, 204.0, 135.2), - ], + &texts[1].clusters, + &[(0, 199.5, 162.08), (3, 199.5, 178.08)], + ); + // The letter-spaced second column steps 18px; its Latin lies on the + // baseline 18px in from its line-over edge, 188. + assert_eq!(texts[2].text, "…—"); + origins(&texts[2].clusters, &[(0, 167.5, 34.08), (3, 167.5, 52.08)]); + assert_eq!(texts[3].text, "ab"); + origins(&texts[3].clusters, &[(0, 170.0, 56.0), (1, 188.0, 56.0)]); + assert_eq!( + transform_origins, + vec![(170.0, 56.0)], + "one quarter turn, for the sideways Latin" ); - // The letter-spaced second column steps 18px. - assert_eq!(texts[7].text, "ab"); - origins(&texts[7].clusters, &[(0, 170.0, 68.8), (1, 170.0, 86.8)]); let annotation = commands .iter() .find_map(|command| match command { @@ -244,18 +330,320 @@ fn a_vertical_column_places_every_glyph_and_rotates_its_marks() { .expect("the rubied base paints its annotation"); // Three 8px kana down the 32px base span: 8px free, one share // (8/3) per glyph, half a share at each edge, every glyph's - // baseline its em-box ascent below its top. + // vertical origin below its place. let ys: Vec = annotation .clusters .iter() .map(|cluster| cluster.y) .collect(); for (y, expected) in ys.iter().zip([ - 148.0 + 4.0 / 3.0 + 6.09375, - 160.0 + 6.09375, - 172.0 - 4.0 / 3.0 + 6.09375, + 148.0 + 4.0 / 3.0 + 7.04, + 160.0 + 7.04, + 172.0 - 4.0 / 3.0 + 7.04, ]) { assert!((y - expected).abs() < 1e-9, "{ys:?}"); } - assert!(annotation.clusters.iter().all(|cluster| cluster.x == 212.0)); + // The annotation's baseline stands 16px past the base's (207, 13px in + // from the line-over edge 220), at 223: its glyphs centre half a pixel + // further and hang half their advance back, at 219.5. + assert!(annotation.clusters.iter().all(|cluster| cluster.x == 219.5)); +} + +/// Text in a vertical ruby box moves by the rounding of the box's start +/// down the line, every pair of one ruby element with its first, while +/// plain text keeps its fractional place (measured: a base glyph's ink +/// steps once per CSS pixel of its element's start at device scales 1 and +/// 2; a later pair of one element steps with the first pair's start, a +/// pair of its own element with its own). +#[test] +fn a_vertical_ruby_base_starts_on_a_whole_pixel() { + let mut inline = InlineStyleTable::new(1); + let black = inline + .intern_for_node(0, body_style(srgb(0.0, 0.0, 0.0, 1.0))) + .expect("style interns"); + let ruby_item = |text: &str, annotation: &str, continues_ruby: bool| InlineItem::Text { + text: text.to_owned(), + style: black, + baseline_shift_px: 0.0, + ruby_annotation: Some(rito_fragment::RubyAnnotation { + text: annotation.to_owned(), + size_ratio: 0.5, + align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby, + }), + }; + let items = vec![ + text_item("春日", black, 0.0), + ruby_item("漢", "かん", false), + ruby_item("字", "じ", true), + ruby_item("春", "はる", false), + ]; + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { items }, + children: Vec::new(), + }], + FormattingNodeId(0), + FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("tree builds"); + // Justified shares leave every piece at a fractional place; the last + // base takes a share ahead of its glyph, which its box start excludes. + let mut ahead = column_run(64.875, 16.375, 12, "春", 16.0); + if let Fragment::Text(run) = &mut ahead { + run.clusters = run + .clusters + .iter() + .map(|cluster| rito_fragment::ClusterPosition { + x: cluster.x + 0.375, + ..*cluster + }) + .collect(); + } + let root = Fragment::Box(BoxFragment { + source: FormattingNodeId(0), + rect: FragmentRect { + x: 0.0, + y: 0.0, + width: 300.0, + height: 60.0, + }, + children: vec![Fragment::Line(LineFragment { + source: FormattingNodeId(0), + marker: None, + rect: FragmentRect { + x: 0.0, + y: 0.0, + width: 81.0, + height: 24.0, + }, + baseline: 13.0, + trailing_whitespace: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, + text_runs: Vec::new(), + children: vec![ + column_run(0.0, 32.375, 0, "春日", 16.1875), + column_run(32.375, 16.25, 6, "漢", 16.25), + column_run(48.625, 16.25, 9, "字", 16.25), + ahead, + ], + })], + cuts: rito_fragment::BlockCuts::NONE, + }); + let measured = |x: f64, byte: u32| rito_fragment::ClusterPosition { + byte, + x, + face: rito_fragment::FontFaceId::default(), + joins: false, + continues_ligature: false, + }; + let annotation = |glyphs: u32| rito_inline::MeasuredRuby { + run: rito_inline::MeasuredRun { + advance: 8.0 * f64::from(glyphs), + clusters: (0..glyphs) + .map(|glyph| measured(8.0 * f64::from(glyph), 3 * glyph)) + .collect(), + grid: false, + }, + over_offset: 16.0, + em_ascent: 6.09375, + orientations: Some( + vec![ + rito_fragment::ClusterOrientation::Upright { + center: -0.5, + half_advance: 4.0, + vertical_origin: 7.04, + }; + glyphs as usize + ] + .into(), + ), + }; + let mut ruby_runs = BTreeMap::new(); + ruby_runs.insert((0, 1), annotation(2)); + ruby_runs.insert((0, 2), annotation(1)); + ruby_runs.insert((0, 3), annotation(2)); + let mut commands = Vec::new(); + append_fragment_display_commands( + &mut commands, + &tree, + &root, + 0.0, + 0.0, + FragmentPaintContext { + vertical_frame: Some(crate::fragment_paint::VerticalFrame { + converter: rito_fragment::WritingModeConverter::from_logical_size( + rito_style_contract::WritingDirection { + mode: rito_style_contract::WritingMode::VerticalRightToLeft, + direction: rito_style_contract::Direction::LeftToRight, + }, + root.rect().width, + 220.0, + ), + left: 0.0, + top: 20.0, + }), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&ruby_runs), + emphasis: None, + }, + ..FragmentPaintContext::default() + }, + ) + .expect("the column paints"); + let places: Vec<(String, Vec)> = commands + .iter() + .filter_map(|command| match command { + DisplayCommand::PaintText(input) => Some(( + input.text.to_string(), + input + .clusters + .iter() + .map(|cluster| cluster.origin().2) + .collect(), + )), + _ => None, + }) + .collect(); + assert_eq!( + places, + vec![ + ("春日".to_owned(), vec![34.08, 50.2675]), + // The element's box starts at 20 + 32.375, rounding to 52: its + // later pair moves by the same 0.375, where its own start + // would round up. + ("漢".to_owned(), vec![66.08]), + ("字".to_owned(), vec![82.33]), + // A ruby element of its own: its box start 84.875 rounds to + // 85, its glyph 0.375 past it. + ("春".to_owned(), vec![99.455]), + ], + "plain runs keep their place, the base starts on a whole pixel" + ); +} + +/// A vertical run's decoration lines run down beside its column, a gap +/// off the em box: Japanese text swaps them, its underline on the +/// line-over (right) side and its overline on the line-under (left) side +/// where Chinese text draws its underline (measured in pinned Chromium on +/// 16px SourceHan, whose font box is 12 + 11 around its baseline: a +/// Japanese underline fills the pixel one in from the text box's over +/// edge). +#[test] +fn a_vertical_decoration_line_runs_beside_the_column_on_its_language_side() { + let line_rects = |language: Option<&str>, overline: bool| -> Vec<(f64, f64, f64, f64)> { + let mut style = body_style(srgb(0.0, 0.0, 0.0, 1.0)); + style.paint.text_decoration.lines.underline = !overline; + style.paint.text_decoration.lines.overline = overline; + style.text_flow.language = language.map(rito_style_contract::LanguageTag::new); + let mut inline = InlineStyleTable::new(1); + let underlined = inline.intern_for_node(0, style).expect("style interns"); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![text_item("第一章", underlined, 0.0)], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("tree builds"); + // The item paints in two pieces; its line runs along both as one. + let pieces: Vec = [ + column_run(0.0, 16.0, 0, "第", 16.0), + column_run(16.0, 32.0, 3, "一章", 16.0), + ] + .into_iter() + .map(|mut piece| { + if let Fragment::Text(text) = &mut piece { + text.font_grid = Some((12.0, 11.0)); + } + piece + }) + .collect(); + let root = Fragment::Box(BoxFragment { + source: FormattingNodeId(0), + rect: FragmentRect { + x: 0.0, + y: 0.0, + width: 300.0, + height: 24.0, + }, + children: vec![Fragment::Line(LineFragment { + source: FormattingNodeId(0), + marker: None, + rect: FragmentRect { + x: 0.0, + y: 0.0, + width: 48.0, + height: 24.0, + }, + baseline: 13.0, + trailing_whitespace: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, + text_runs: Vec::new(), + children: pieces, + })], + cuts: rito_fragment::BlockCuts::NONE, + }); + let mut commands = Vec::new(); + append_fragment_display_commands( + &mut commands, + &tree, + &root, + 0.0, + 0.0, + FragmentPaintContext { + vertical_frame: Some(crate::fragment_paint::VerticalFrame { + converter: rito_fragment::WritingModeConverter::from_logical_size( + rito_style_contract::WritingDirection { + mode: rito_style_contract::WritingMode::VerticalRightToLeft, + direction: rito_style_contract::Direction::LeftToRight, + }, + root.rect().width, + 220.0, + ), + left: 0.0, + top: 20.0, + }), + ..FragmentPaintContext::default() + }, + ) + .expect("the column paints"); + commands + .iter() + .filter_map(|command| match command { + DisplayCommand::PaintDecoration { rect, .. } => { + Some((rect.x, rect.y, rect.width, rect.height)) + } + _ => None, + }) + .collect() + }; + // The column's baseline lies 13px in from its line-over edge, the + // frame's right edge 220: at 207, its text box's over edge at 219. + let at = |x: f64| vec![(x, 20.0, 1.0, 48.0)]; + assert_eq!(line_rects(Some("ja"), false), at(217.0)); + assert_eq!(line_rects(Some("ja"), true), at(198.0)); + assert_eq!(line_rects(Some("zh-CN"), false), at(198.0)); + assert_eq!(line_rects(None, false), at(198.0)); + // Other text's overline lies just outside its text box's over edge. + assert_eq!(line_rects(Some("zh-CN"), true), at(219.0)); } diff --git a/crates/rito-core/src/fragment_paint/text_run.rs b/crates/rito-core/src/fragment_paint/text_run.rs index 6e96f8503..9b70bb712 100644 --- a/crates/rito-core/src/fragment_paint/text_run.rs +++ b/crates/rito-core/src/fragment_paint/text_run.rs @@ -18,9 +18,10 @@ use crate::render::{ TextCluster, }; +use super::emphasis; use super::family::{face_family, split_by_face}; use super::run_style::run_paint; -use super::{cluster_x, painted_baseline, snap_css, CANVAS_TOP_ASCENT_RATIO}; +use super::{cluster_x, painted_baseline, snap_css, LineAnnotations, CANVAS_TOP_ASCENT_RATIO}; /// One inline item's runs on one line: the item's byte range there, its /// left and right edges, and the ruby overhang its outermost runs carry. @@ -103,7 +104,7 @@ pub(super) fn append_text_run_command( line_y: f64, faces: Option<&[std::sync::Arc]>, item_sources: Option<&[FlowItemSource]>, - ruby_annotation_runs: Option<&BTreeMap<(u32, usize), rito_inline::MeasuredRuby>>, + annotations: LineAnnotations<'_>, snap_origin_y: f64, ratio: f64, ) -> EpubResult<()> { @@ -251,8 +252,8 @@ pub(super) fn append_text_run_command( // satisfies this law too: round(559.671875) + 15 = 575 — // the earlier per-stage-ceil reading fit that one point but // not the phase sweep. - let line_top = line_y + line.ruby_growth; - let within_line = line.baseline - baseline_shift_px - line.ruby_growth; + let line_top = line_y + line.annotation_growth; + let within_line = line.baseline - baseline_shift_px - line.annotation_growth; ( painted_baseline(snap_origin_y, line_top, within_line, ratio), snap_origin_y + snap_css(line_top - snap_origin_y) + within_line, @@ -345,7 +346,8 @@ pub(super) fn append_text_run_command( // this segment's words are one contiguous slice of it, re-based // to their first cluster, and the computed `ruby-align` places // every cluster over the segment's extent. - let measured = ruby_annotation_runs + let measured = annotations + .ruby .and_then(|runs| runs.get(&(line.source.0, *item_index))) .ok_or_else(|| { EpubError::new("ruby annotation painted before its string was measured") @@ -370,6 +372,7 @@ pub(super) fn append_text_run_command( face: cluster.face, // Distribution moves every annotation cluster on its own. joins: false, + continues_ligature: false, }) .collect(); let slice_end = measured @@ -423,10 +426,14 @@ pub(super) fn append_text_run_command( // Every cluster keeps its origin, at its offset from its segment's; // one the pen reaches from the one before it says so, so a pen drawing // strings draws it in the same string and a pen drawing clusters still - // has its origin. A segment's first cluster starts a string. - let clusters = run + // has its origin. A segment's first cluster starts a string. A + // ligature paints as one cluster from its start, so a pen drawing + // clusters forms it too. + let clusters: std::sync::Arc<[TextCluster]> = run .clusters_with_origins() - .map(|(segment_x, cluster)| { + .enumerate() + .filter(|(index, (_, cluster))| *index == 0 || !cluster.continues_ligature) + .map(|(_, (segment_x, cluster))| { let origin_x = line_x + segment_x + run.ruby_center_shift_px; TextCluster { byte: cluster.byte - run.text_start, @@ -436,6 +443,34 @@ pub(super) fn append_text_run_command( } }) .collect(); + // Emphasis marks centre on the graphemes where they paint. + let marks = match annotations.emphasis.and_then(|marks| marks.get(style_id)) { + Some(measured) => { + let places = emphasis::mark_places( + &full_text[start..end], + &emphasis::cluster_origins(run, line_x), + emphasis::last_advance_end( + line, + run, + items, + text_ranges, + styles, + line_x, + cluster_origin_x + run.rect.width, + ), + ); + emphasis::horizontal_marks( + style, + measured, + faces, + &paint, + emphasis::run_faces(run), + &places, + baseline, + )? + } + None => None, + }; let command = DisplayTextCommand { text: SharedText::slice(full_text, start..end), // A halt-trimmed opener was laid at half width, but the painter @@ -464,6 +499,7 @@ pub(super) fn append_text_run_command( // A run drawn from one face paints as it is. if run.clusters.iter().all(|cluster| cluster.face == run_face) { commands.push(DisplayCommand::PaintText(command)); + commands.extend(marks); return Ok(()); } // A ruby spread base fused across a fallback face paints once per @@ -490,5 +526,6 @@ pub(super) fn append_text_run_command( })? { commands.push(DisplayCommand::PaintText(piece)); } + commands.extend(marks); Ok(()) } diff --git a/crates/rito-core/src/fragment_paint/vertical.rs b/crates/rito-core/src/fragment_paint/vertical.rs index 761278a73..aee00755d 100644 --- a/crates/rito-core/src/fragment_paint/vertical.rs +++ b/crates/rito-core/src/fragment_paint/vertical.rs @@ -1,54 +1,187 @@ -//! Vertical-rl lines painted as downward columns. Each line box becomes a -//! column placed in from the frame's right edge; a text run paints glyph -//! by glyph one step (font size plus letter spacing) down the column, -//! with bracket, dash and leader marks rotated a quarter turn about their -//! em centre and comma and period marks shifted into the em's top-right -//! corner; an inline image swaps its layout box back to physical; a ruby -//! annotation spreads its glyphs down the base span. +//! Vertical-rl lines painted as columns. Each line box is a column placed +//! in from the frame's right edge, and its clusters land where layout put +//! them down the line, across from the run's baseline by what layout +//! measured for each: an upright cluster's glyph, in its face's vertical +//! forms, from its horizontal origin, half its advance left of its centre +//! line and its face's vertical origin below its place (the column's top +//! on the whole-pixel grid, as the browser offsets it); a sideways stretch +//! as horizontal text turned a quarter clockwise about its start on its +//! alphabetic baseline; a combined upright unit as one horizontal string +//! where layout set it in its em, unturned, in its chosen forms and scaled +//! about its start when they too overflow. An inline image swaps its +//! layout box back to physical, and so does an inline-block, whose own +//! lines then paint from its physical corner: across the column as +//! horizontal lines when it is an orthogonal flow, as columns of its own +//! otherwise. A ruby annotation spreads its glyphs down the base span, and +//! a decoration line runs down beside the column. -use rito_fragment::{FormattingNodeContent, FormattingTree, Fragment, InlineItem, LineFragment}; +use rito_fragment::{ + ClusterOrientation, CombinedForms, FontFaceId, FormattingNodeContent, FormattingTree, Fragment, + InlineItem, LineFragment, TextFragment, +}; use std::collections::BTreeMap; use crate::epub::{EpubError, EpubResult}; -use crate::fragment_bridge::FlowItemSource; +use crate::fragment_bridge::{FlowItemSource, NodePaint}; use crate::render::contract::{ReaderPoint, ReaderRect, ReaderSize, ReaderTransform}; use crate::render::{ display_number, display_rect, DisplayCommand, DisplayTextCommand, RunPaint, TextCluster, }; -use super::family::{face_family, face_pieces}; +use super::emphasis; +use super::family::{combined_forms_family, face_family, vertical_forms_family}; +use super::flow_text::FlowTexts; +use super::image::append_image_command; +use super::line::append_line_commands; use super::run_style::run_paint; -use super::CANVAS_TOP_ASCENT_RATIO; +use super::{cluster_x, snap_css, LineAnnotations, CANVAS_TOP_ASCENT_RATIO}; + +/// How a placed cluster paints. +#[derive(Clone, Copy, PartialEq)] +enum Stance { + /// Upright at its own origin. + Upright, + /// Part of a stretch turned a quarter clockwise. + Sideways, + /// Part of a combined upright unit's string, which the unit's first + /// cluster places. + Combined { + scale_x: f64, + forms: CombinedForms, + continues: bool, + }, +} + +impl Stance { + /// Whether a cluster standing `next` joins the command of a stretch + /// this stance starts: one stance runs on, and a combined unit takes + /// exactly its own clusters. + fn joined_by(self, next: Stance) -> bool { + match (self, next) { + (Stance::Combined { .. }, Stance::Combined { continues, .. }) => continues, + (Stance::Combined { .. }, _) | (_, Stance::Combined { .. }) => false, + (stance, next) => stance == next, + } + } + + /// The forms the stance's face paints in. + fn forms(self) -> Forms { + match self { + Stance::Upright => Forms::Vertical, + Stance::Sideways => Forms::Horizontal, + Stance::Combined { forms, .. } => Forms::Combined(forms), + } + } +} -/// Punctuation that takes its vertical presentation in a column: brackets, -/// dashes and leaders are the horizontal glyph rotated a quarter turn -/// about its em center (how the `vert` feature draws them); comma and -/// period marks sit in the em's top-right corner instead of bottom-left. -const VERTICAL_ROTATED: &str = "「」『』()()〔〕[][]{}{}〈〉《》【】〖〗…‥ー―—–~〜~=="; -const VERTICAL_SHIFTED: &str = "、。,.,."; +/// The forms a face paints a cluster in: its horizontal ones, its +/// vertical ones, or a combined unit's. +#[derive(Clone, Copy, PartialEq)] +enum Forms { + Horizontal, + Vertical, + Combined(CombinedForms), +} -/// Paints one column run glyph by glyph: every code point sits upright -/// one step (the font size plus letter spacing) below the last, its -/// baseline 0.8 em below the run's top, the way the browser's column pen -/// stepped. Consecutive upright glyphs share one run with a cluster -/// origin each; a rotated mark paints as its own run under a quarter-turn -/// transform about its em center. -fn append_vertical_run_commands( +/// One cluster of a column run where it paints: its byte in the flow +/// text, its place down the line, its glyph origin (a sideways cluster's +/// baseline across), its face and its stance. +#[derive(Clone, Copy)] +struct Placed { + byte: usize, + place: f64, + x: f64, + y: f64, + face: FontFaceId, + stance: Stance, +} + +/// Where a column run's clusters paint: `baseline_x` is the run's +/// baseline, `top` the column's top on the whole-pixel grid. +fn place_column_run(run: &TextFragment, font_size: f64, baseline_x: f64, top: f64) -> Vec { + run.clusters_with_origins() + .enumerate() + .map(|(index, (segment_x, cluster))| { + let place = cluster_x( + top + segment_x + run.ruby_center_shift_px + cluster.x, + run.cluster_grid, + ); + let byte = cluster.byte as usize; + let orientation = run + .orientations + .as_deref() + .and_then(|orientations| orientations.get(index).copied()) + .unwrap_or(ClusterOrientation::Upright { + center: 0.0, + half_advance: font_size / 2.0, + vertical_origin: CANVAS_TOP_ASCENT_RATIO * font_size, + }); + let (x, y, stance) = match orientation { + ClusterOrientation::Sideways { baseline } => { + (baseline_x - baseline, 0.0, Stance::Sideways) + } + // The unit's string hangs from its top on the whole-pixel + // grid, as the browser rounds the box's line before + // drawing its text. + ClusterOrientation::Combined { + across, + down, + scale_x, + forms, + continues, + } => ( + baseline_x - across, + place.round() + down, + Stance::Combined { + scale_x, + forms, + continues, + }, + ), + ClusterOrientation::Upright { + center, + half_advance, + vertical_origin, + } => ( + baseline_x - center - half_advance, + place + vertical_origin, + Stance::Upright, + ), + }; + Placed { + byte, + place, + x, + y, + face: cluster.face, + stance, + } + }) + .collect() +} + +/// Paints a column run's placed clusters: consecutive clusters of one +/// face and stance share a command, upright ones in the face's vertical +/// forms, and a sideways stretch turns a quarter clockwise about its start +/// on its baseline. `end` is the run's last byte and `run_bottom` where it +/// ends down the line. +#[allow(clippy::too_many_arguments)] +fn append_column_run_commands( commands: &mut Vec, - text: &str, - paint: &RunPaint, + full_text: &str, + placed: &[Placed], + end: usize, + run_bottom: f64, font_size: f64, - glyph_x: f64, - top: f64, + paint_for: &dyn Fn(FontFaceId, Forms) -> EpubResult, href: Option, -) { - let step = font_size + paint.letter_spacing_px.unwrap_or(0.0); - let run = |text: String, rect: ReaderRect, clusters: Vec| { +) -> EpubResult<()> { + let text_command = |text: &str, rect: ReaderRect, clusters: Vec, paint| { DisplayCommand::PaintText(DisplayTextCommand { text: text.into(), rect, - paint: paint.clone(), + paint, line_height_px: None, href: href.clone(), source_text: None, @@ -56,98 +189,133 @@ fn append_vertical_run_commands( clusters: clusters.into(), }) }; - let mut segment: Vec = Vec::new(); - let mut segment_start = 0usize; - let mut segment_end = 0usize; - let mut segment_top = top; - let flush = |commands: &mut Vec, - segment: &mut Vec, - start: usize, - end: usize, - segment_top: f64| { - if segment.is_empty() { - return; + let mut start = 0; + while start < placed.len() { + let first = placed[start]; + let mut stop = start + 1; + while placed.get(stop).is_some_and(|next| { + first.stance.joined_by(next.stance) + && (next.face == first.face || matches!(first.stance, Stance::Combined { .. })) + }) { + stop += 1; } - let length = segment.len() as f64 * step; - commands.push(run( - text[start..end].to_owned(), - display_rect(glyph_x, segment_top, font_size, length), - std::mem::take(segment), - )); - }; - for (index, (byte, glyph)) in text.char_indices().enumerate() { - let glyph_top = top + index as f64 * step; - let pen_y = glyph_top + CANVAS_TOP_ASCENT_RATIO * font_size; - if VERTICAL_ROTATED.contains(glyph) { - flush(commands, &mut segment, segment_start, byte, segment_top); - let center_x = glyph_x + font_size / 2.0; - let center_y = pen_y - 0.3 * font_size; - commands.push(DisplayCommand::PushState); - commands.push(DisplayCommand::Transform { - origin: ReaderPoint { - x: display_number(center_x), - y: display_number(center_y), - }, - box_size: ReaderSize { - width: display_number(font_size), - height: display_number(font_size), - }, - transforms: vec![ReaderTransform::Rotate { - radians: std::f64::consts::FRAC_PI_2, - }], - }); - commands.push(run( - glyph.to_string(), - display_rect(glyph_x, glyph_top, font_size, font_size), - vec![TextCluster::at(0, glyph_x, pen_y)], - )); - commands.push(DisplayCommand::PopState); - segment_start = byte + glyph.len_utf8(); - segment_end = segment_start; - continue; - } - if segment.is_empty() { - segment_start = byte; - segment_top = glyph_top; + let group_end = placed.get(stop).map_or(end, |next| next.byte); + let bottom = placed.get(stop).map_or(run_bottom, |next| next.place); + let length = bottom - first.place; + let text = &full_text[first.byte..group_end]; + let paint = paint_for(first.face, first.stance.forms())?; + match first.stance { + Stance::Upright => { + let clusters = placed[start..stop] + .iter() + .map(|cluster| { + TextCluster::at((cluster.byte - first.byte) as u32, cluster.x, cluster.y) + }) + .collect(); + commands.push(text_command( + text, + display_rect(first.x, first.place, font_size, length), + clusters, + paint, + )); + } + Stance::Sideways => { + // Set horizontally from the stretch's start on its + // baseline, then turned a quarter clockwise about it: the + // text runs down the column, its ascent towards the + // line-over side. + let origin = (first.x, first.place); + let clusters = placed[start..stop] + .iter() + .map(|cluster| { + TextCluster::at( + (cluster.byte - first.byte) as u32, + origin.0 + (cluster.place - first.place), + origin.1, + ) + }) + .collect(); + commands.push(DisplayCommand::PushState); + commands.push(DisplayCommand::Transform { + origin: ReaderPoint { + x: display_number(origin.0), + y: display_number(origin.1), + }, + box_size: ReaderSize { + width: display_number(length), + height: display_number(font_size), + }, + transforms: vec![ReaderTransform::Rotate { + radians: std::f64::consts::FRAC_PI_2, + }], + }); + commands.push(text_command( + text, + display_rect( + origin.0, + origin.1 - CANVAS_TOP_ASCENT_RATIO * font_size, + length, + font_size, + ), + clusters, + paint, + )); + commands.push(DisplayCommand::PopState); + } + Stance::Combined { scale_x, .. } => { + // One horizontal string from its start on its baseline; + // scaled, about that start. + let width = 1.1 * font_size; + let command = text_command( + text, + display_rect(first.x, first.place, width, length), + vec![TextCluster::at(0, first.x, first.y)], + paint, + ); + if scale_x == 1.0 { + commands.push(command); + } else { + commands.push(DisplayCommand::PushState); + commands.push(DisplayCommand::Transform { + origin: ReaderPoint { + x: display_number(first.x), + y: display_number(first.y), + }, + box_size: ReaderSize { + width: display_number(width), + height: display_number(length), + }, + transforms: vec![ReaderTransform::Scale { + sx: scale_x, + sy: 1.0, + }], + }); + commands.push(command); + commands.push(DisplayCommand::PopState); + } + } } - let origin = if VERTICAL_SHIFTED.contains(glyph) { - (glyph_x + 0.5 * font_size, pen_y - 0.6 * font_size) - } else { - (glyph_x, pen_y) - }; - segment.push(TextCluster::at( - (byte - segment_start) as u32, - origin.0, - origin.1, - )); - segment_end = byte + glyph.len_utf8(); + start = stop; } - flush( - commands, - &mut segment, - segment_start, - segment_end, - segment_top, - ); + Ok(()) } -/// Paints one vertical-rl line box as a downward text column. The line -/// laid out with the horizontal engine in the swapped page (inline axis -/// = column length), so `box_inline`/`box_block` are LOGICAL offsets: -/// the accumulated block offset measures in from the frame's right edge -/// and the inline offset down from its top. +/// Paints one vertical-rl line box as a downward text column whose +/// physical left edge is `column_x` and top `column_top` on the page. #[allow(clippy::too_many_arguments)] pub(super) fn append_vertical_line_commands( commands: &mut Vec, tree: &FormattingTree, line: &LineFragment, - box_inline: f64, - box_block: f64, - frame_right: f64, - frame_top: f64, + column_x: f64, + column_top: f64, faces: Option<&[std::sync::Arc]>, + image_border_paints: Option<&BTreeMap>, flow_item_sources: Option<&BTreeMap>>, - ruby_annotation_runs: Option<&BTreeMap<(u32, usize), rito_inline::MeasuredRuby>>, + annotations: LineAnnotations<'_>, + snap_origin_y: f64, + ratio: f64, + flow_texts: &FlowTexts, ) -> EpubResult<()> { let FormattingNodeContent::InlineFlow { items } = &tree.node(line.source).content else { return Err(EpubError::new("line fragment source is not an inline flow")); @@ -157,41 +325,95 @@ pub(super) fn append_vertical_line_commands( let styles = tree .styles() .ok_or_else(|| EpubError::new("formatting tree carries no style tables"))?; - let mut full_text = String::new(); - let mut text_ranges: Vec<(std::ops::Range, usize)> = Vec::new(); - for (item_index, item) in items.iter().enumerate() { - if let InlineItem::Text { text, .. } = item { - let start = full_text.len(); - full_text.push_str(text); - text_ranges.push((start..full_text.len(), item_index)); - } - } - let column_x = frame_right - (box_block + line.rect.y + line.rect.height); - let column_top = frame_top + box_inline + line.rect.x; + let flow_text = flow_texts.of(line.source, items); + let (full_text, text_ranges) = (&flow_text.text, flow_text.item_ranges.as_slice()); + // The ruby box the last ruby base on the line sat in: that base's + // item and how far the box's glyphs move. + let mut ruby_box: Option<(usize, f64)> = None; + // The column's decoration lines, joined along each item and painted + // under the column's glyphs. + let line_start = commands.len(); + let mut decorations: Vec<(usize, DisplayCommand)> = Vec::new(); for child in &line.children { // A replaced atom in a vertical line: the layout box is the // swapped one (advance = physical height), so the device rect - // swaps back — column position from the line, the atom's inline - // offset down the page, physical width x height — and the - // raster paints unrotated, clipping at the page edge like the - // reference. + // swaps back — the atom's inline offset down the page, physical + // width x height — and the raster paints unrotated, clipping at + // the page edge like the reference. if let Fragment::Image(image) = child { - let Some(InlineItem::Image { src, .. }) = items.get(image.item_index as usize) else { - continue; - }; let item_source = item_sources.and_then(|sources| sources.get(image.item_index as usize)); - commands.push(DisplayCommand::paint_image( - src.clone(), - display_rect( - column_x, - column_top + image.rect.x, - image.rect.height, - image.rect.width, - ), - item_source.and_then(|source| source.image_alt.clone()), - item_source.and_then(|source| source.href.clone()), - )); + // Its block offset measures in from the line-over (right) + // edge, like every block offset in the line. Back in physical + // terms it paints as any inline image does: placed by its + // `object-fit` and snapped to whole CSS pixels. + let physical = rito_fragment::ImageFragment { + rect: rito_fragment::FragmentRect { + x: column_x + line.rect.height - image.rect.y - image.rect.height, + y: column_top + image.rect.x, + width: image.rect.height, + height: image.rect.width, + }, + ..image.clone() + }; + append_image_command( + commands, + items, + &physical, + 0.0, + 0.0, + image_border_paints, + item_source, + )?; + continue; + } + if let Fragment::Box(atom) = child { + let left = column_x + line.rect.height - atom.rect.y - atom.rect.height; + let top = column_top + atom.rect.x; + let horizontal = tree + .strut_style(atom.source) + .and_then(|style| styles.inline.style(style).ok()) + .is_some_and(|style| { + style.bidi.writing_mode + == rito_style_contract::WritingMode::HorizontalTopToBottom + }); + for inner in &atom.children { + let Fragment::Line(inner_line) = inner else { + continue; + }; + if horizontal { + append_line_commands( + commands, + tree, + inner_line, + left, + top, + faces, + image_border_paints, + flow_item_sources, + annotations, + snap_origin_y, + ratio, + flow_texts, + )?; + } else { + // Its columns stand in from its line-over (right) edge. + append_vertical_line_commands( + commands, + tree, + inner_line, + left + atom.rect.height - inner_line.rect.y - inner_line.rect.height, + top + inner_line.rect.x, + faces, + image_border_paints, + flow_item_sources, + annotations, + snap_origin_y, + ratio, + flow_texts, + )?; + } + } continue; } let Fragment::Text(run) = child else { continue }; @@ -204,8 +426,9 @@ pub(super) fn append_vertical_line_commands( continue; }; let InlineItem::Text { - style, + style: style_id, ruby_annotation, + baseline_shift_px, .. } = &items[*item_index] else { @@ -213,70 +436,126 @@ pub(super) fn append_vertical_line_commands( }; let style = styles .inline - .style(*style) + .style(*style_id) .map_err(|error| EpubError::new(format!("text run has no inline style: {error}")))?; - // A column run paints glyphs only: its inline box and decoration - // have no column expression yet. - let paint_for = |face| -> EpubResult { + // A column run paints its glyphs and decoration lines: its inline + // box has no column expression yet. + // Upright glyphs paint in their face's vertical forms, the + // variant the host registers beside each face it names. + let family_for = |face, forms: Forms| -> EpubResult> { + let family = face_family(style, faces, face)?; + if faces.is_none() { + return Ok(family); + } + Ok(match forms { + Forms::Horizontal | Forms::Combined(CombinedForms::Proportional) => family, + Forms::Vertical => std::sync::Arc::from(vertical_forms_family(&family)), + Forms::Combined(forms) => { + std::sync::Arc::from(combined_forms_family(forms, &family)) + } + }) + }; + let paint_for = |face, forms: Forms| -> EpubResult { Ok(run_paint( style, - face_family(style, faces, face)?, + family_for(face, forms)?, run.justify_px, false, false, )? .glyphs_only()) }; - let run_face = run - .clusters - .first() - .map(|cluster| cluster.face) - .unwrap_or_default(); - let base_paint = paint_for(run_face)?; let font_size = f64::from(style.font.size.get()); - // The glyph column centers on the line's STRUT: an annotation's - // growth lands entirely on the line's right (matrix-measured: - // the base keeps its plain-line distance from the left edge and - // the annotation column pushes the right edge out), so the - // centering basis excludes the growth. - let glyph_x = column_x + (line.rect.height - line.ruby_growth - font_size) / 2.0; - // Each face's glyphs paint as their own column piece, stepping on - // from where the previous piece's glyphs ended. - let step = font_size + base_paint.letter_spacing_px.unwrap_or(0.0); - let run_faces: Vec<_> = run.clusters.iter().map(|cluster| cluster.face).collect(); - let pieces = face_pieces(&run_faces); - let mut glyphs_before = 0usize; - for (index, (range, face)) in pieces.iter().enumerate() { - let piece_start = run - .clusters - .get(range.start) - .map_or(start, |cluster| cluster.byte as usize); - let piece_end = pieces - .get(index + 1) - .and_then(|(next, _)| run.clusters.get(next.start)) - .map_or(end, |cluster| cluster.byte as usize); - let text = &full_text[piece_start..piece_end]; - append_vertical_run_commands( - commands, - text, - &if index == 0 { - base_paint.clone() - } else { - paint_for(*face)? - }, - font_size, - glyph_x, - column_top + run.rect.x + glyphs_before as f64 * step, - item_sources - .and_then(|sources| sources.get(*item_index)) - .and_then(|source| source.href.clone()), + // The run's baseline lies the line's baseline in from its + // line-over edge, less the item's own shift. + let baseline_x = column_x + line.rect.height - (line.baseline - baseline_shift_px); + let mut top = snap_css(column_top); + // Text in a ruby box paints from the box's place rounded to a whole + // CSS pixel down the line, as the browser paints a box's text: it + // shifts the text by the rounding of the box's inline start, then + // rasters each glyph on its own (measured: a base glyph's ink + // steps once per CSS pixel of its box's start at device scales 1 + // and 2, a plain glyph's with its own place, and the later pairs of + // one ruby element step with the first). The box starts where the + // element opens on the line, before its base's inset and before a + // justification share its first glyph takes ahead of itself. + if let Some(annotation) = ruby_annotation { + let same_box = ruby_box.is_some_and(|(last, _)| { + last == *item_index || (annotation.continues_ruby && last + 1 == *item_index) + }); + let shift = match ruby_box { + Some((_, shift)) if same_box => shift, + _ => run + .clusters_with_origins() + .next() + .map_or(0.0, |(segment_x, _)| { + let start = top + segment_x; + snap_css(start) - start + }), + }; + ruby_box = Some((*item_index, shift)); + top += shift; + } + let placed = place_column_run(run, font_size, baseline_x, top); + // The underline and overline paint under the glyphs, as the + // browser paints them, one line along each item. + for decoration in + super::vertical_decoration::vertical_decorations(style, run, baseline_x, top)? + { + super::vertical_decoration::push_joined(&mut decorations, *item_index, decoration); + } + append_column_run_commands( + commands, + full_text, + &placed, + end, + top + run.rect.x + run.rect.width, + font_size, + &paint_for, + item_sources + .and_then(|sources| sources.get(*item_index)) + .and_then(|source| source.href.clone()), + )?; + // Emphasis marks centre on the graphemes down the column; a + // combined upright unit takes none. + if let Some(measured) = annotations + .emphasis + .and_then(|marks| marks.get(style_id)) + .filter(|_| { + style.bidi.text_combine_upright == rito_style_contract::TextCombineUpright::None + }) + { + let places = emphasis::mark_places( + &full_text[start..end], + &emphasis::cluster_origins(run, top), + emphasis::last_advance_end( + line, + run, + items, + text_ranges, + styles, + top, + top + run.rect.x + run.rect.width, + ), ); - glyphs_before += text.chars().count(); + let run_face = run + .clusters + .first() + .map(|cluster| cluster.face) + .unwrap_or_default(); + commands.extend(emphasis::vertical_marks( + style, + measured, + faces, + &paint_for(run_face, Forms::Horizontal)?, + emphasis::run_faces(run), + &places, + baseline_x, + )?); } - // The annotation rides the column's LEFT-out side? No: probed on - // a vertical-rl ruby line, the annotation column sits between - // the base and the NEXT line — its right edge on the line box's - // right edge (rt 803.5..812.5 in a 782..812 line box, size 8). + // The annotation stands on the base's line-over side, wherever + // the line's own edge is (a first line's annotation reaches into + // the space before the line without growing it). if let Some(annotation) = ruby_annotation { let item_range = { let (range, _) = &text_ranges[text_ranges @@ -307,30 +586,67 @@ pub(super) fn append_vertical_line_commands( ); if !allocated.is_empty() { let annotation_size = font_size * f64::from(annotation.size_ratio); - // Down a column every annotation glyph paints at its - // alphabetic baseline one em-box ascent below its - // glyph top (the annotation's own typo ascent, what - // the browser's canvas resolves a top anchor to). - let em_ascent = ruby_annotation_runs + let measured_ruby = annotations + .ruby .and_then(|runs| runs.get(&(line.source.0, *item_index))) .ok_or_else(|| { EpubError::new( "vertical ruby annotation painted before its string was measured", ) - })? - .em_ascent; - let measured_ruby = ruby_annotation_runs - .and_then(|runs| runs.get(&(line.source.0, *item_index))); + })?; // A column annotation paints in the face its first // cluster shaped with. let annotation_face = measured_ruby - .and_then(|measured| measured.run.clusters.first()) + .run + .clusters + .first() .map(|cluster| cluster.face) .unwrap_or_default(); - let ruby_paint = base_paint - .for_ruby(annotation_size) - .with_family(face_family(style, faces, annotation_face)?); - let annotation_x = column_x + line.rect.height - annotation_size; + let base_paint = paint_for( + run.clusters + .first() + .map(|cluster| cluster.face) + .unwrap_or_default(), + Forms::Horizontal, + )?; + let upright = matches!( + measured_ruby + .orientations + .as_deref() + .and_then(|orientations| orientations.first()), + Some(ClusterOrientation::Upright { .. }) + ); + let ruby_paint = base_paint.for_ruby(annotation_size).with_family(family_for( + annotation_face, + if upright { + Forms::Vertical + } else { + Forms::Horizontal + }, + )?); + // The annotation's baseline stands `over_offset` past + // the base's on the line-over side (the base's em box + // and the annotation's under it), and its glyphs hang + // from it as a column run's do. + let annotation_baseline_x = baseline_x + measured_ruby.over_offset; + let (glyph_x, vertical_origin) = match measured_ruby + .orientations + .as_deref() + .and_then(|orientations| orientations.first()) + { + Some(ClusterOrientation::Upright { + center, + half_advance, + vertical_origin, + }) => ( + annotation_baseline_x - center - half_advance, + *vertical_origin, + ), + _ => ( + annotation_baseline_x - annotation_size / 2.0, + measured_ruby.em_ascent, + ), + }; let span_top = column_top + run.rect.x - run.ruby_overhang_px; let span = run.rect.width + run.ruby_overhang_px + run.ruby_overhang_right_px; // The column annotation spreads down its base span @@ -346,17 +662,17 @@ pub(super) fn append_vertical_line_commands( .map(|(index, (byte, _))| { TextCluster::at( byte as u32, - annotation_x, + glyph_x, span_top + share / 2.0 + index as f64 * (annotation_size + share) - + em_ascent, + + vertical_origin, ) }) .collect(); commands.push(DisplayCommand::PaintRuby(DisplayTextCommand { text: allocated.into(), - rect: display_rect(annotation_x, span_top, annotation_size, span), + rect: display_rect(glyph_x, span_top, annotation_size, span), paint: ruby_paint, line_height_px: None, href: None, @@ -368,5 +684,9 @@ pub(super) fn append_vertical_line_commands( } } } + commands.splice( + line_start..line_start, + decorations.into_iter().map(|(_, line)| line), + ); Ok(()) } diff --git a/crates/rito-core/src/fragment_paint/vertical_decoration.rs b/crates/rito-core/src/fragment_paint/vertical_decoration.rs new file mode 100644 index 000000000..505be1351 --- /dev/null +++ b/crates/rito-core/src/fragment_paint/vertical_decoration.rs @@ -0,0 +1,118 @@ +//! A vertical run's decoration lines, where Chromium draws them: beside the +//! column, a gap off the em box, at whatever fractional place the text box +//! puts them (measured against pinned Chromium at 12–32px: the line keeps +//! the text box's half-pixel phases). + +use rito_fragment::TextFragment; +use rito_style_contract::InlineFormattingStyle; + +use crate::epub::EpubResult; +use crate::render::{display_rect, DisplayCommand}; + +use super::family::css_color; + +/// The underline and overline of `run` drawn down the page from `top` for +/// its length, across from the run's baseline at `baseline_x`; +/// none when the run draws neither or its font box did not resolve. +/// +/// Blink places each line from the text box's line-over edge, measuring +/// the em box from half the font's integer height (the run's font box, +/// `ascent` + `descent` around its central baseline). Japanese and Korean +/// text swaps the two lines: its +/// underline sits past the em box's over edge by a one-pixel gap and the +/// line's thickness (`ComputeUnderlineOffsetForUnder` at +/// `TopOfEmHeight`), its overline one pixel past the em box's under edge +/// (at `BottomOfEmHeight`, where other text puts its underline), and other +/// text's overline sits just outside the text box's over edge (at +/// `TextTop`). +pub(super) fn vertical_decorations( + style: &InlineFormattingStyle, + run: &TextFragment, + baseline_x: f64, + top: f64, +) -> EpubResult> { + let decoration = &style.paint.text_decoration; + let lines = decoration.lines; + if !lines.underline && !lines.overline { + return Ok(Vec::new()); + } + let Some((ascent, descent)) = run.font_grid else { + return Ok(Vec::new()); + }; + let font_size = f64::from(style.font.size.get()); + let thickness = (font_size / 10.0).floor().max(1.0); + let height = ascent + descent; + let half_over = height - (height * 32.0).trunc() / 64.0; + let layout_unit = |value: f64| (value * 64.0).round() / 64.0; + let over_em = layout_unit(half_over - font_size / 2.0).floor() - 1.0 - thickness; + let under_em = layout_unit(half_over + font_size / 2.0).floor() + 1.0; + let swapped = swaps_lines(style); + let mut offsets = Vec::new(); + if lines.underline { + offsets.push(if swapped { over_em } else { under_em }); + } + if lines.overline { + offsets.push(if swapped { under_em } else { -thickness }); + } + // The text box's over edge, `ascent` over the run's baseline (the + // run's font box, as its records place it). + let over_edge = baseline_x + ascent; + let color = css_color(decoration.color.resolve(style.paint.foreground))?; + Ok(offsets + .into_iter() + .map(|from_over_edge| { + let right = over_edge - from_over_edge; + DisplayCommand::PaintDecoration { + rect: display_rect( + right - thickness, + top + run.rect.x, + thickness, + run.rect.width, + ), + color, + } + }) + .collect()) +} + +/// Whether vertical text swaps its underline and overline: text whose +/// language writes kana or hangul (Blink's `ResolveUnderlinePosition` +/// over the font's script). +fn swaps_lines(style: &InlineFormattingStyle) -> bool { + style.text_flow.language.as_ref().is_some_and(|tag| { + let primary = tag.as_str().split(['-', '_']).next().unwrap_or(""); + primary.eq_ignore_ascii_case("ja") || primary.eq_ignore_ascii_case("ko") + }) +} + +/// Adds item `item`'s `line` to the decoration lines collected for one +/// column, joined to the line before it where it continues it: the browser +/// strokes one line along a text item that the column paints in pieces. +pub(super) fn push_joined( + lines: &mut Vec<(usize, DisplayCommand)>, + item: usize, + line: DisplayCommand, +) { + if let ( + Some(( + last_item, + DisplayCommand::PaintDecoration { + rect: last, + color: last_color, + }, + )), + DisplayCommand::PaintDecoration { rect, color }, + ) = (lines.last_mut(), &line) + { + if *last_item == item + && last.x == rect.x + && last.width == rect.width + && last_color == color + && (last.y + last.height - rect.y).abs() < 1e-3 + { + last.height = rect.y + rect.height - last.y; + return; + } + } + lines.push((item, line)); +} diff --git a/crates/rito-core/src/fragment_paint/walk.rs b/crates/rito-core/src/fragment_paint/walk.rs index b1a47f2d8..cde54a5f3 100644 --- a/crates/rito-core/src/fragment_paint/walk.rs +++ b/crates/rito-core/src/fragment_paint/walk.rs @@ -6,7 +6,7 @@ //! into, its outside list marker, and the dispatch of every child line to //! the horizontal or the vertical line painter. -use rito_fragment::{FormattingTree, Fragment}; +use rito_fragment::{FormattingNodeContent, FormattingTree, Fragment}; use crate::epub::{EpubError, EpubResult}; use crate::fragment_bridge::NodePaint; @@ -19,11 +19,14 @@ use crate::render::{ display_number, display_rect, DisplayCommand, DisplayTextCommand, RunPaint, TextCluster, }; +use super::box_slice::slice_cut_edges; use super::family::{face_family, split_by_face}; use super::line::append_line_commands; use super::run_style::run_paint; use super::vertical::append_vertical_line_commands; -use super::{cluster_x, painted_baseline, snap_css, FragmentPaintContext, CANVAS_TOP_ASCENT_RATIO}; +use super::{ + cluster_x, painted_baseline, snap_css, FlowTexts, FragmentPaintContext, CANVAS_TOP_ASCENT_RATIO, +}; #[allow(clippy::too_many_arguments)] pub(super) fn append_fragment_display_commands_inner( @@ -34,6 +37,7 @@ pub(super) fn append_fragment_display_commands_inner( origin_y: f64, context: FragmentPaintContext<'_>, snap_origin_y: f64, + flow_texts: &FlowTexts, ) -> EpubResult<()> { match fragment { Fragment::Box(fragment) => { @@ -64,8 +68,12 @@ pub(super) fn append_fragment_display_commands_inner( // column for column). The rigid shift to that rounded // position is a translate composed BEFORE the author // transforms, in the un-rotated frame. - let box_x = origin_x + fragment.rect.x; - let box_y = origin_y + fragment.rect.y; + let (box_x, box_y, box_width, box_height) = context.page_rect( + origin_x + fragment.rect.x, + origin_y + fragment.rect.y, + fragment.rect.width, + fragment.rect.height, + ); let (snap_dx, snap_dy) = (snap_css(box_x) - box_x, snap_css(box_y) - box_y); let ops = if snap_dx == 0.0 && snap_dy == 0.0 { transforms.clone() @@ -79,16 +87,23 @@ pub(super) fn append_fragment_display_commands_inner( }; commands.push(DisplayCommand::Transform { origin: ReaderPoint { - x: display_number(box_x + fragment.rect.width / 2.0), - y: display_number(box_y + fragment.rect.height / 2.0), + x: display_number(box_x + box_width / 2.0), + y: display_number(box_y + box_height / 2.0), }, box_size: ReaderSize { - width: display_number(fragment.rect.width), - height: display_number(fragment.rect.height), + width: display_number(box_width), + height: display_number(box_height), }, transforms: ops, }); } + // The box's page rectangle: what its decoration paints. + let (page_x, page_y, page_width, page_height) = context.page_rect( + origin_x + fragment.rect.x, + origin_y + fragment.rect.y, + fragment.rect.width, + fragment.rect.height, + ); if let Some(paint) = node_paint { match paint { NodePaint::Rule { @@ -108,7 +123,7 @@ pub(super) fn append_fragment_display_commands_inner( // right edge — a border box of two colours, whose // corners the border lowering miters where the // colours meet. - let thickness = thickness.min(fragment.rect.height); + let thickness = thickness.min(page_height); if *style == ReaderBorderStyle::Inset { let edge = |color: ReaderColor| { Some(ReaderBorderEdgePaint { @@ -120,12 +135,7 @@ pub(super) fn append_fragment_display_commands_inner( let light = ReaderColor::srgb8(0xee, 0xee, 0xee, 1.0); let width = display_number(thickness); commands.push(DisplayCommand::PaintBlock { - rect: display_rect( - origin_x + fragment.rect.x, - origin_y + fragment.rect.y, - fragment.rect.width, - fragment.rect.height, - ), + rect: display_rect(page_x, page_y, page_width, page_height), paint: Box::new(ReaderBlockPaint { border: Some(ReaderBlockBorder { top: edge(dark), @@ -144,12 +154,7 @@ pub(super) fn append_fragment_display_commands_inner( }); } else { commands.push(DisplayCommand::PaintHorizontalRule { - rect: display_rect( - origin_x + fragment.rect.x, - origin_y + fragment.rect.y, - fragment.rect.width, - thickness, - ), + rect: display_rect(page_x, page_y, page_width, thickness), paint: ReaderHorizontalRulePaint { color: *color, style: *style, @@ -174,7 +179,17 @@ pub(super) fn append_fragment_display_commands_inner( // rule per cell segment instead. let mut paint = paint.clone(); let mut border_box = *border_box; - if *segment_horizontal_edges { + slice_cut_edges( + &mut paint, + &mut border_box, + fragment.cuts, + context.vertical_frame.as_ref(), + ); + // The cell edges the split follows are the + // table's flow-relative ones, which only a + // horizontal table's top and bottom edges run + // along. + if *segment_horizontal_edges && context.vertical_frame.is_none() { let segmented = split_collapsed_horizontal_edges( &mut paint, &mut border_box, @@ -194,12 +209,7 @@ pub(super) fn append_fragment_display_commands_inner( || !paint.box_shadows.is_empty(); if has_decoration { commands.push(DisplayCommand::PaintBlock { - rect: display_rect( - origin_x + fragment.rect.x, - origin_y + fragment.rect.y, - fragment.rect.width, - fragment.rect.height, - ), + rect: display_rect(page_x, page_y, page_width, page_height), paint: Box::new(paint.clone()), border_box: *border_box, }); @@ -222,12 +232,10 @@ pub(super) fn append_fragment_display_commands_inner( }); // The strips ride the same whole-pixel // edges the border strokes snap to. - let left_edge = snap_css(origin_x + fragment.rect.x); - let top_edge = snap_css(origin_y + fragment.rect.y); - let right_edge = - snap_css(origin_x + fragment.rect.x + fragment.rect.width); - let bottom_edge = - snap_css(origin_y + fragment.rect.y + fragment.rect.height); + let left_edge = snap_css(page_x); + let top_edge = snap_css(page_y); + let right_edge = snap_css(page_x + page_width); + let bottom_edge = snap_css(page_y + page_height); let (x, y) = (left_edge, top_edge); let (width, height) = (right_edge - left_edge, bottom_edge - top_edge); @@ -319,8 +327,8 @@ pub(super) fn append_fragment_display_commands_inner( let line_y = origin_y + fragment.rect.y + first_line.rect.y; let baseline = painted_baseline( child_snap_origin_y, - line_y + first_line.ruby_growth, - first_line.baseline - first_line.ruby_growth, + line_y + first_line.annotation_growth, + first_line.baseline - first_line.annotation_growth, context.ratio, ); // The box's inline size is the shaped string's @@ -369,20 +377,36 @@ pub(super) fn append_fragment_display_commands_inner( // it downward: text runs paint as upright downward // columns, an inline image keeps its physical box and a // ruby annotation runs down beside its base. - if let (Fragment::Line(line), Some((frame_right, frame_top))) = - (child, context.vertical_frame) - { + if let (Fragment::Line(line), Some(_)) = (child, context.vertical_frame) { + let (column_x, column_top, _, _) = context.page_rect( + origin_x + fragment.rect.x + line.rect.x, + origin_y + fragment.rect.y + line.rect.y, + line.rect.width, + line.rect.height, + ); append_vertical_line_commands( commands, tree, line, - origin_x + fragment.rect.x, - origin_y + fragment.rect.y, - frame_right, - frame_top, + column_x, + column_top, context.faces, + context.image_border_paints, context.flow_item_sources, - context.ruby_annotation_runs, + context.annotations, + child_snap_origin_y, + context.ratio, + flow_texts, + )?; + append_atom_block_commands( + commands, + tree, + line, + origin_x + fragment.rect.x + line.rect.x, + origin_y + fragment.rect.y + line.rect.y, + context, + child_snap_origin_y, + flow_texts, )?; continue; } @@ -394,6 +418,7 @@ pub(super) fn append_fragment_display_commands_inner( origin_y + fragment.rect.y, context, child_snap_origin_y, + flow_texts, )?; } if transformed { @@ -401,25 +426,92 @@ pub(super) fn append_fragment_display_commands_inner( } Ok(()) } - Fragment::Line(line) => append_line_commands( - commands, - tree, - line, - origin_x, - origin_y, - context.faces, - context.image_border_paints, - context.flow_item_sources, - context.ruby_annotation_runs, - snap_origin_y, - context.ratio, - ), + Fragment::Line(line) => { + append_line_commands( + commands, + tree, + line, + origin_x, + origin_y, + context.faces, + context.image_border_paints, + context.flow_item_sources, + context.annotations, + snap_origin_y, + context.ratio, + flow_texts, + )?; + append_atom_block_commands( + commands, + tree, + line, + origin_x + line.rect.x, + origin_y + line.rect.y, + context, + snap_origin_y, + flow_texts, + ) + } Fragment::Text(_) | Fragment::Image(_) => Err(EpubError::new( "text and image fragments paint through their line box, not standalone", )), } } +/// The block content of the inline blocks riding `line`, whose box sits at +/// (`origin_x`, `origin_y`) in the walk's coordinates: an inline-block that +/// holds blocks is a block container, and its children paint as any block +/// subtree in the atom's frame on the line (an inline-block's own lines +/// paint with the line). Only one in the chapter's writing mode shares its +/// coordinates; an orthogonal one's blocks are not painted yet. +#[allow(clippy::too_many_arguments)] +fn append_atom_block_commands( + commands: &mut Vec, + tree: &FormattingTree, + line: &rito_fragment::LineFragment, + origin_x: f64, + origin_y: f64, + context: FragmentPaintContext<'_>, + snap_origin_y: f64, + flow_texts: &FlowTexts, +) -> EpubResult<()> { + for child in &line.children { + let Fragment::Box(atom) = child else { + continue; + }; + if !matches!( + tree.node(atom.source).content, + FormattingNodeContent::BlockContainer + ) { + continue; + } + let vertical = tree + .styles() + .and_then(|styles| styles.layout.style(tree.node(atom.source).style).ok()) + .is_some_and(|style| { + style.writing_mode != rito_style_contract::WritingMode::HorizontalTopToBottom + }); + if vertical != context.vertical_frame.is_some() { + continue; + } + for inner in &atom.children { + if matches!(inner, Fragment::Box(_)) { + append_fragment_display_commands_inner( + commands, + tree, + inner, + origin_x + atom.rect.x, + origin_y + atom.rect.y, + context, + snap_origin_y, + flow_texts, + )?; + } + } + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] /// Splits a collapsed table's dashed/dotted horizontal border edges into /// per-cell rule commands: the collapsed border belongs to the cells, so diff --git a/crates/rito-core/src/fragment_window.rs b/crates/rito-core/src/fragment_window.rs new file mode 100644 index 000000000..6bb8f1db2 --- /dev/null +++ b/crates/rito-core/src/fragment_window.rs @@ -0,0 +1,265 @@ +//! A chapter laid out as one continuous strip, read through windows the +//! size of the viewport: the scrolled reading mode's pages. +//! +//! A window is a slice of the chapter's block extent — its block-start +//! margin, the strip and its block-end margin: down the screen for +//! horizontal text, leftwards for vertical right-to-left text. Its records +//! hold what starts inside it, in CSS pixels from the window's block start, +//! the same at every device ratio. Its paint covers whole device rows (or +//! columns): window `k` starts `D_k = floor(k × length × ratio + 0.5)` +//! device pixels into the chapter along its axis and ends where the next +//! starts, so the windows tile the chapter with no seam. A window paints +//! and lowers in its chapter's coordinates and moves onto its own origin by +//! whole device pixels last (`render::shift`), so every edge and baseline +//! rounds where it does when the whole chapter paints at once. +//! +//! Painting or recording a window walks only the fragments that reach it: +//! [`window_paint_tree`] and [`window_record_tree`] cut a copy of the +//! strip down to them. + +use std::ops::Range; + +use rito_fragment::{BoxFragment, FormattingNodeContent, FormattingTree, Fragment}; + +use crate::epub::PageProgression; +use crate::fragment_bridge::NodePaint; + +/// The way a scrolled book's chapters follow one another, and a chapter's +/// windows with them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScrollAxis { + /// Down the screen, as horizontal text's blocks stack. + Down, + /// Leftwards, as vertical right-to-left text's columns follow. + Left, +} + +impl ScrollAxis { + /// A book's axis: scrolling on through a book is moving on through its + /// pages, which a book whose pages progress right to left does + /// leftwards. + pub(crate) fn of_book(progression: PageProgression) -> Self { + match progression { + PageProgression::RightToLeft => Self::Left, + PageProgression::LeftToRight | PageProgression::Default => Self::Down, + } + } + + /// The axis a chapter's own blocks run along. + pub(crate) fn of_chapter(vertical: bool) -> Self { + if vertical { + Self::Left + } else { + Self::Down + } + } +} + +/// How a chapter's block extent slices into windows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ScrollWindows { + /// A full window's block length in CSS pixels: the viewport's height, + /// or its width for a chapter that runs leftwards. + length: f64, + /// The chapter's block extent in CSS pixels: its block-start margin, + /// its strip and its block-end margin. + extent: f64, + axis: ScrollAxis, +} + +impl ScrollWindows { + pub(crate) fn new(length: f64, extent: f64, axis: ScrollAxis) -> Self { + Self { + length, + extent, + axis, + } + } + + pub(crate) fn axis(&self) -> ScrollAxis { + self.axis + } + + /// A full window's block length in CSS pixels. + pub(crate) fn length(&self) -> f64 { + self.length + } + + /// The chapter's block extent in CSS pixels. + pub(crate) fn extent(&self) -> f64 { + self.extent + } + + /// How many windows the chapter takes: always one at least, the last + /// shorter than the rest when the extent is not a whole number of them. + pub(crate) fn count(&self) -> usize { + ((self.extent / self.length).ceil() as usize).max(1) + } + + /// The block range window `window` records, in the chapter's CSS + /// pixels. + pub(crate) fn range(&self, window: usize) -> Range { + let start = window as f64 * self.length; + start..(start + self.length).min(self.extent) + } + + /// The device pixels of the chapter along its axis that window + /// `window` paints at `ratio`, from the chapter's block start. + pub(crate) fn device_rows(&self, window: usize, ratio: f64) -> Range { + let row = |index: usize| (index as f64 * self.length * ratio + 0.5).floor(); + let end = if window + 1 >= self.count() { + (self.extent * ratio + 0.5).floor() + } else { + row(window + 1) + }; + row(window)..end + } + + /// Where window `window`'s top-left corner sits in the chapter painted + /// at `ratio`, in device pixels: the shift that moves the chapter onto + /// the window's own origin, reversed. A chapter running leftwards + /// starts at the right edge of a window `length` wide whose left edge + /// is the chapter's origin, `floor(length × ratio + 0.5)` device + /// pixels left of that edge, so its first window is where a page is. + pub(crate) fn device_origin(&self, window: usize, ratio: f64) -> (f64, f64) { + let rows = self.device_rows(window, ratio); + match self.axis { + ScrollAxis::Down => (0.0, rows.start), + ScrollAxis::Left => ((self.length * ratio + 0.5).floor() - rows.end, 0.0), + } + } +} + +/// The strip cut down to what can ink the block range `range` (chapter +/// CSS pixels, the strip's root at `origin_y`): every line and box whose +/// rect comes within `margin` of it. A box is also kept for a kept child +/// when its own rect lies outside, since overflow is not clipped to it. A +/// table and a transformed box keep their whole subtree: a collapsed +/// table's edges are painted from its neighbouring cells, and a transform +/// carries ink anywhere. +pub(crate) fn window_paint_tree( + tree: &FormattingTree, + node_paints: Option<&std::collections::BTreeMap>, + root: &Fragment, + origin_y: f64, + range: Range, + margin: f64, +) -> Fragment { + let reach = (range.start - margin)..(range.end + margin); + let whole = |fragment: &BoxFragment| { + matches!( + tree.node(fragment.source).content, + FormattingNodeContent::Table + ) || matches!( + node_paints.and_then(|paints| paints.get(&fragment.source.0)), + Some(NodePaint::Box { + transform: Some(_), + .. + }) + ) + }; + prune(root, origin_y, &|fragment, top| { + let bottom = top + fragment.rect().height; + let inside = bottom >= reach.start && top <= reach.end; + match fragment { + Fragment::Box(fragment) if whole(fragment) => Keep::Whole(inside), + Fragment::Box(_) => Keep::Children { alone: inside }, + _ => Keep::Whole(inside), + } + }) +} + +/// The strip cut down to what window range `range` (chapter CSS pixels, +/// the strip's root at `origin_y`) records: every line whose top lies in +/// it, with the boxes that hold them, and every box with no lines whose +/// top lies in it. A line belongs to the window it starts in, so each line +/// is recorded by exactly one window. +pub(crate) fn window_record_tree(root: &Fragment, origin_y: f64, range: Range) -> Fragment { + prune(root, origin_y, &|fragment, top| { + let starts_inside = range.contains(&top); + match fragment { + Fragment::Box(_) => Keep::Children { + alone: starts_inside && !holds_lines(fragment), + }, + _ => Keep::Whole(starts_inside), + } + }) +} + +/// What a cut keeps of one fragment, given its rect. +enum Keep { + /// The fragment and everything in it, when the flag holds. + Whole(bool), + /// The box with whichever of its children are kept; when none is, the + /// box alone if `alone` holds. + Children { alone: bool }, +} + +/// `fragment` (its parent's origin at `parent_y`) cut by `keep`, which +/// reads a fragment and the absolute top of its rect. The root is kept +/// whatever it holds. +fn prune(fragment: &Fragment, parent_y: f64, keep: &dyn Fn(&Fragment, f64) -> Keep) -> Fragment { + let Fragment::Box(root) = fragment else { + return fragment.clone(); + }; + let top = parent_y + root.rect.y; + Fragment::Box(BoxFragment { + children: root + .children + .iter() + .filter_map(|child| prune_child(child, top, keep)) + .collect(), + ..cut_box(root) + }) +} + +fn prune_child( + fragment: &Fragment, + parent_y: f64, + keep: &dyn Fn(&Fragment, f64) -> Keep, +) -> Option { + let top = parent_y + fragment.rect().y; + match keep(fragment, top) { + Keep::Whole(kept) => kept.then(|| fragment.clone()), + Keep::Children { alone } => { + let Fragment::Box(inner) = fragment else { + return alone.then(|| fragment.clone()); + }; + let children: Vec = inner + .children + .iter() + .filter_map(|child| prune_child(child, top, keep)) + .collect(); + (!children.is_empty() || alone).then(|| { + Fragment::Box(BoxFragment { + children, + ..cut_box(inner) + }) + }) + } + } +} + +/// A box's own fields, its children left out for the cut to fill. +fn cut_box(fragment: &BoxFragment) -> BoxFragment { + BoxFragment { + source: fragment.source, + rect: fragment.rect, + children: Vec::new(), + // A window's edge is not a fragmentation break: the box keeps the + // edges it has in the continuous strip. + cuts: fragment.cuts, + } +} + +/// Whether any line box lies under `fragment`. +fn holds_lines(fragment: &Fragment) -> bool { + match fragment { + Fragment::Line(_) => true, + Fragment::Box(inner) => inner.children.iter().any(holds_lines), + Fragment::Text(_) | Fragment::Image(_) => false, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/rito-core/src/fragment_window/tests.rs b/crates/rito-core/src/fragment_window/tests.rs new file mode 100644 index 000000000..94003fde2 --- /dev/null +++ b/crates/rito-core/src/fragment_window/tests.rs @@ -0,0 +1,217 @@ +use rito_fragment::{ + BoxFragment, FormattingNode, FormattingNodeContent, FormattingNodeId, FormattingTree, Fragment, + FragmentRect, LineFragment, +}; +use rito_style_contract::LayoutStyleId; + +use super::{window_paint_tree, window_record_tree, ScrollAxis, ScrollWindows}; + +const RATIOS: [f64; 6] = [1.0, 1.25, 1.5, 2.0, 2.625, 3.0]; + +#[test] +fn windows_tile_the_chapter_row_for_row_at_every_ratio() { + for extent in [950.0, 1000.0, 2849.5, 31_337.25] { + let windows = ScrollWindows::new(950.0, extent, ScrollAxis::Down); + assert_eq!(windows.count(), (extent / 950.0_f64).ceil() as usize); + assert_eq!(windows.range(windows.count() - 1).end, extent); + for ratio in RATIOS { + assert_eq!(windows.device_rows(0, ratio).start, 0.0); + for window in 1..windows.count() { + assert_eq!( + windows.device_rows(window - 1, ratio).end, + windows.device_rows(window, ratio).start, + "extent {extent} ratio {ratio} window {window}" + ); + let rows = windows.device_rows(window - 1, ratio); + assert!((rows.end - rows.start - 950.0 * ratio).abs() <= 1.0); + } + let last = windows.device_rows(windows.count() - 1, ratio); + assert_eq!(last.end, (extent * ratio + 0.5).floor()); + } + } +} + +#[test] +fn leftward_windows_tile_the_chapter_column_for_column_from_its_right_edge() { + for extent in [390.0, 411.5, 1_170.25, 31_337.25] { + let windows = ScrollWindows::new(390.0, extent, ScrollAxis::Left); + for ratio in RATIOS { + let right = (390.0_f64 * ratio + 0.5).floor(); + let width = |window: usize| { + let rows = windows.device_rows(window, ratio); + rows.end - rows.start + }; + // The first window ends at the chapter's right edge, where a + // page's would. + let (x, y) = windows.device_origin(0, ratio); + assert_eq!( + (x + width(0), y), + (right, 0.0), + "extent {extent} ratio {ratio}" + ); + for window in 1..windows.count() { + let (x, _) = windows.device_origin(window, ratio); + assert_eq!(x.fract(), 0.0); + assert_eq!( + x + width(window), + windows.device_origin(window - 1, ratio).0, + "extent {extent} ratio {ratio} window {window}" + ); + } + let last = windows.count() - 1; + assert_eq!( + windows.device_origin(last, ratio).0, + right - (extent * ratio + 0.5).floor() + ); + } + } +} + +#[test] +fn downward_windows_start_on_their_first_row() { + let windows = ScrollWindows::new(950.0, 2849.5, ScrollAxis::Down); + for ratio in RATIOS { + for window in 0..windows.count() { + assert_eq!( + windows.device_origin(window, ratio), + (0.0, windows.device_rows(window, ratio).start) + ); + } + } +} + +#[test] +fn a_chapter_shorter_than_a_window_is_one_window() { + let windows = ScrollWindows::new(950.0, 0.0, ScrollAxis::Down); + assert_eq!(windows.count(), 1); + assert_eq!(windows.range(0), 0.0..0.0); +} + +/// A strip of three blocks: a block of three lines ending on a window +/// boundary (its last line straddles it), a tall block whose only line +/// lies two windows down, and a table below both. +fn strip() -> (FormattingTree, Fragment) { + let node = |content, children: Vec| FormattingNode { + style: LayoutStyleId::from_raw(0), + content, + children: children.into_iter().map(FormattingNodeId).collect(), + }; + let tree = FormattingTree::new( + vec![ + node(FormattingNodeContent::BlockContainer, vec![1, 2, 3]), + node(FormattingNodeContent::BlockContainer, vec![]), + node(FormattingNodeContent::BlockContainer, vec![]), + node(FormattingNodeContent::Table, vec![]), + ], + FormattingNodeId(0), + ) + .expect("tree builds"); + let rect = |y, height| FragmentRect { + x: 0.0, + y, + width: 600.0, + height, + }; + let line = |y, height| { + Fragment::Line(LineFragment { + source: FormattingNodeId(1), + rect: rect(y, height), + baseline: 0.0, + marker: None, + trailing_whitespace: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: rito_fragment::LineFonts::None, + text_runs: Vec::new(), + children: Vec::new(), + }) + }; + let block = |source, y, height, children| { + Fragment::Box(BoxFragment { + source: FormattingNodeId(source), + rect: rect(y, height), + children, + cuts: rito_fragment::BlockCuts::NONE, + }) + }; + let root = block( + 0, + 0.0, + 2900.0, + vec![ + block( + 1, + 0.0, + 1000.0, + vec![line(0.0, 20.0), line(500.0, 20.0), line(980.0, 40.0)], + ), + block(2, 1000.0, 1500.0, vec![line(1400.0, 20.0)]), + block( + 3, + 2500.0, + 400.0, + vec![block(2, 0.0, 400.0, vec![line(10.0, 20.0)])], + ), + ], + ); + (tree, root) +} + +fn children(fragment: &Fragment) -> &[Fragment] { + match fragment { + Fragment::Box(fragment) => &fragment.children, + Fragment::Line(line) => &line.children, + _ => &[], + } +} + +fn line_tops(fragment: &Fragment, parent_y: f64, out: &mut Vec) { + let top = parent_y + fragment.rect().y; + if let Fragment::Line(_) = fragment { + out.push(top); + } + for child in children(fragment) { + line_tops(child, top, out); + } +} + +#[test] +fn a_window_paints_the_lines_that_reach_it_and_a_box_it_lies_in() { + let (tree, root) = strip(); + let painted = window_paint_tree(&tree, None, &root, 0.0, 1000.0..2000.0, 0.0); + let blocks = children(&painted); + assert_eq!( + blocks.iter().map(Fragment::source).collect::>(), + [FormattingNodeId(1), FormattingNodeId(2)], + "the straddling line's block and the tall block the window lies in, not the table" + ); + let mut tops = Vec::new(); + line_tops(&painted, 0.0, &mut tops); + assert_eq!(tops, [980.0], "only the line that reaches into the window"); + assert!(children(&blocks[1]).is_empty()); + + // A table is painted whole once it reaches the window. + let painted = window_paint_tree(&tree, None, &root, 0.0, 2600.0..2700.0, 0.0); + let mut tops = Vec::new(); + line_tops(&painted, 0.0, &mut tops); + assert_eq!(tops, [2510.0]); +} + +#[test] +fn each_line_is_recorded_by_the_one_window_it_starts_in() { + let (_, root) = strip(); + let windows = ScrollWindows::new(1000.0, 2900.0, ScrollAxis::Down); + let mut recorded = Vec::new(); + for window in 0..windows.count() { + let records = window_record_tree(&root, 0.0, windows.range(window)); + let mut tops = Vec::new(); + line_tops(&records, 0.0, &mut tops); + assert!(tops.iter().all(|top| windows.range(window).contains(top))); + recorded.extend(tops); + } + let mut all = Vec::new(); + line_tops(&root, 0.0, &mut all); + assert_eq!(recorded, all); +} diff --git a/crates/rito-core/src/interaction.rs b/crates/rito-core/src/interaction.rs index 40cbe1c70..438a3b44e 100644 --- a/crates/rito-core/src/interaction.rs +++ b/crates/rito-core/src/interaction.rs @@ -3,7 +3,7 @@ pub const OWNS: &str = "Hit maps, locators, selection, search, anchors, annotati mod text; -pub(crate) use text::{plain_word_around, plain_word_boundaries, WordSide}; +pub(crate) use text::{plain_word_around, plain_word_edges, WordSide}; pub use text::{ TextCaretAddress, TextCaretAffinity, TextCaretGeometry, TextInteractionUnavailableReason, TextSelectionBoundary, TextSelectionMovement, diff --git a/crates/rito-core/src/interaction/text.rs b/crates/rito-core/src/interaction/text.rs index ec495d528..d2f4b33a1 100644 --- a/crates/rito-core/src/interaction/text.rs +++ b/crates/rito-core/src/interaction/text.rs @@ -6,4 +6,4 @@ pub use types::{ TextSelectionBoundary, TextSelectionMovement, }; -pub(crate) use word_segmentation::{plain_word_around, plain_word_boundaries, WordSide}; +pub(crate) use word_segmentation::{plain_word_around, plain_word_edges, WordSide}; diff --git a/crates/rito-core/src/interaction/text/word_segmentation.rs b/crates/rito-core/src/interaction/text/word_segmentation.rs index ee113b38d..629a3c359 100644 --- a/crates/rito-core/src/interaction/text/word_segmentation.rs +++ b/crates/rito-core/src/interaction/text/word_segmentation.rs @@ -1,14 +1,24 @@ use icu_locale_core::LanguageIdentifier; use icu_segmenter::{ - options::{WordBreakInvariantOptions, WordBreakOptions}, + options::{WordBreakInvariantOptions, WordBreakOptions, WordType}, WordSegmenter, }; /// All word-segmentation boundaries of plain text as UTF-16 offsets /// (starts with 0, ends with the text length), for movement resolvers. pub(crate) fn plain_word_boundaries(text: &str, language: Option<&str>) -> Vec { + plain_word_edges(text, language) + .into_iter() + .map(|(edge, _)| edge) + .collect() +} + +/// The word-segmentation boundaries of plain text as UTF-16 offsets, each +/// with whether the segment ending there is word-like (letters, numbers, +/// ideographs) rather than spaces or punctuation. +pub(crate) fn plain_word_edges(text: &str, language: Option<&str>) -> Vec<(u32, bool)> { let utf16 = text.encode_utf16().collect::>(); - word_boundaries(&utf16, language) + word_edges(&utf16, language) } /// Which of the two segments meeting at a word boundary a caret there @@ -30,8 +40,7 @@ pub(crate) fn plain_word_around( side: WordSide, language: Option<&str>, ) -> Option<(u32, u32)> { - let utf16 = text.encode_utf16().collect::>(); - word_boundaries(&utf16, language) + plain_word_boundaries(text, language) .windows(2) .map(|pair| (pair[0], pair[1])) .find(|&(start, end)| match side { @@ -40,18 +49,25 @@ pub(crate) fn plain_word_around( }) } -fn word_boundaries(utf16: &[u16], language: Option<&str>) -> Vec { +fn word_edges(utf16: &[u16], language: Option<&str>) -> Vec<(u32, bool)> { // Runtime currently retains package language, not element-level `lang`. // Invalid or unsupported metadata must preserve invariant segmentation. if let Some(language) = language.and_then(parse_language) { let mut options = WordBreakOptions::default(); options.content_locale = Some(&language); if let Ok(segmenter) = WordSegmenter::try_new_auto(options) { - return collect_boundaries(segmenter.as_borrowed().segment_utf16(utf16)); + return collect_edges( + segmenter + .as_borrowed() + .segment_utf16(utf16) + .iter_with_word_type(), + ); } } - collect_boundaries( - WordSegmenter::new_auto(WordBreakInvariantOptions::default()).segment_utf16(utf16), + collect_edges( + WordSegmenter::new_auto(WordBreakInvariantOptions::default()) + .segment_utf16(utf16) + .iter_with_word_type(), ) } @@ -59,9 +75,9 @@ fn parse_language(language: &str) -> Option { language.parse().ok() } -fn collect_boundaries(boundaries: impl Iterator) -> Vec { - boundaries - .filter_map(|boundary| u32::try_from(boundary).ok()) +fn collect_edges(edges: impl Iterator) -> Vec<(u32, bool)> { + edges + .filter_map(|(edge, word)| Some((u32::try_from(edge).ok()?, word.is_word_like()))) .collect() } diff --git a/crates/rito-core/src/layout.rs b/crates/rito-core/src/layout.rs index 7bd0027ba..51e54c751 100644 --- a/crates/rito-core/src/layout.rs +++ b/crates/rito-core/src/layout.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; pub enum SpreadMode { Single, Double, + /// Each chapter one continuous strip, shown through windows the size of + /// the viewport: every window its own spread. + Scrolled, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -79,7 +82,8 @@ impl LayoutConfig { pub fn create_layout_config(input: LayoutConfigInput) -> LayoutConfig { let margins = resolve_margins(input.margin); - let spread_mode = if input.width < input.height { + // A portrait viewport has no room for two pages side by side. + let spread_mode = if input.spread == SpreadMode::Double && input.width < input.height { SpreadMode::Single } else { input.spread @@ -88,7 +92,7 @@ pub fn create_layout_config(input: LayoutConfigInput) -> LayoutConfig { SpreadMode::Double => { spread_on_device_grid(input.width, input.spread_gap, input.device_pixel_ratio) } - SpreadMode::Single => (input.width, input.spread_gap), + SpreadMode::Single | SpreadMode::Scrolled => (input.width, input.spread_gap), }; LayoutConfig { @@ -209,6 +213,27 @@ mod tests { assert_eq!(config.margin_top, 30.0); } + #[test] + fn a_portrait_viewport_scrolls_as_it_was_asked() { + let config = create_layout_config(LayoutConfigInput { + width: 390.0, + height: 844.0, + margin: MarginInput::All(20.0), + spread: SpreadMode::Scrolled, + first_page_alone: true, + spread_gap: 20.0, + root_font_size: 16.0, + line_height_override: None, + line_height_force: None, + font_family_override: None, + font_family_force: None, + device_pixel_ratio: 3.0, + }); + + assert_eq!(config.spread_mode, SpreadMode::Scrolled); + assert_eq!((config.page_width, config.page_height), (390.0, 844.0)); + } + fn spread(width: f64, ratio: f64) -> (f64, f64) { let config = create_layout_config(LayoutConfigInput { width, diff --git a/crates/rito-core/src/lib.rs b/crates/rito-core/src/lib.rs index 1d54fa331..7d790c2ff 100644 --- a/crates/rito-core/src/lib.rs +++ b/crates/rito-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod epub; pub mod fragment_bridge; pub(crate) mod fragment_pagination; pub(crate) mod fragment_paint; +pub(crate) mod fragment_window; pub mod interaction; pub mod layout; pub mod render; diff --git a/crates/rito-core/src/render.rs b/crates/rito-core/src/render.rs index 7df4e0fb7..6d3f28f64 100644 --- a/crates/rito-core/src/render.rs +++ b/crates/rito-core/src/render.rs @@ -14,6 +14,8 @@ pub(crate) use commands::{ contract, display_number, display_rect, encode_reader_primitive_list, summarize_display_list_resource_refs, DisplayCommand, DisplayTextCommand, TextCluster, }; -pub(crate) use lower::{lower, ImageSize}; +pub(crate) use lower::{lower, shift, ImageSize}; +#[cfg(test)] +pub(crate) use lower::{Primitive, PrimitiveList}; pub(crate) use run_paint::RunPaint; pub(crate) use shared_text::SharedText; diff --git a/crates/rito-core/src/render/commands.rs b/crates/rito-core/src/render/commands.rs index f4d63a654..4b8464dbe 100644 --- a/crates/rito-core/src/render/commands.rs +++ b/crates/rito-core/src/render/commands.rs @@ -13,7 +13,7 @@ pub(crate) use reader_wire::{contract, encode_reader_primitive_list}; pub(crate) use refs::summarize_display_list_resource_refs; use contract::{ - ReaderBlockPaint, ReaderBorderBox, ReaderCornerRadius, ReaderHorizontalRulePaint, + ReaderBlockPaint, ReaderBorderBox, ReaderColor, ReaderCornerRadius, ReaderHorizontalRulePaint, ReaderPagePaint, ReaderPoint, ReaderRect, ReaderSize, ReaderTransform, }; @@ -64,6 +64,12 @@ pub(crate) enum DisplayCommand { rect: ReaderRect, paint: ReaderHorizontalRulePaint, }, + /// A text decoration line its run cannot carry, filled as it lies + /// (a vertical run's line runs down beside the column). + PaintDecoration { + rect: ReaderRect, + color: ReaderColor, + }, } /// A painted text run or ruby annotation. diff --git a/crates/rito-core/src/render/commands/test_support/parse.rs b/crates/rito-core/src/render/commands/test_support/parse.rs index 653b8d077..4fe6bdbc8 100644 --- a/crates/rito-core/src/render/commands/test_support/parse.rs +++ b/crates/rito-core/src/render/commands/test_support/parse.rs @@ -93,6 +93,16 @@ pub(crate) fn parse_display_command(value: &Value) -> Result DisplayCommand::PaintDecoration { + rect: parse_rect( + field(object, "rect", "paintDecoration.rect")?, + "paintDecoration.rect", + )?, + color: parse_color( + field_string(object, "color", "paintDecoration.color")?, + "paintDecoration.color", + )?, + }, _ => return Err(FixtureError::UnsupportedValue("command.kind")), }) } diff --git a/crates/rito-core/src/render/commands/test_support/write.rs b/crates/rito-core/src/render/commands/test_support/write.rs index c38f515ba..53d54dc0b 100644 --- a/crates/rito-core/src/render/commands/test_support/write.rs +++ b/crates/rito-core/src/render/commands/test_support/write.rs @@ -34,6 +34,7 @@ fn kind_name(command: &DisplayCommand) -> &'static str { DisplayCommand::PaintRuby(_) => "paintRuby", DisplayCommand::PaintImage { .. } => "paintImage", DisplayCommand::PaintHorizontalRule { .. } => "paintHorizontalRule", + DisplayCommand::PaintDecoration { .. } => "paintDecoration", } } @@ -126,6 +127,10 @@ fn command_value(command: &DisplayCommand) -> Value { json!({ "color": color_css(paint.color), "style": border_style_name(paint.style) }), ); } + DisplayCommand::PaintDecoration { rect, color } => { + fields.insert("rect".to_owned(), rect_value(rect)); + fields.insert("color".to_owned(), Value::String(color_css(*color))); + } } Value::Object(fields) } diff --git a/crates/rito-core/src/render/lower.rs b/crates/rito-core/src/render/lower.rs index de571badf..ccc56eae1 100644 --- a/crates/rito-core/src/render/lower.rs +++ b/crates/rito-core/src/render/lower.rs @@ -41,6 +41,7 @@ mod json; mod path; mod primitive; mod scale; +mod shift; #[cfg(test)] mod tests; mod text; @@ -49,6 +50,7 @@ pub(crate) use primitive::{ DashPattern, DevicePath, DevicePoint, DeviceRect, DeviceTransform, FillRule, Ground, IconPiece, ImageFallbackState, PathOp, Primitive, PrimitiveList, StrokeCap, TilePlan, }; +pub(crate) use shift::shift; /// An image's intrinsic size in CSS pixels, as the publication's resource /// table records it; background images size and tile against it. @@ -170,6 +172,11 @@ fn lower_command( DisplayCommand::PaintHorizontalRule { rect, paint } => { border::lower_horizontal_rule(rect.into(), paint, out); } + DisplayCommand::PaintDecoration { rect, color } => out.push(Primitive::FillRect { + rect: rect.into(), + color: *color, + ground: Ground::None, + }), } } diff --git a/crates/rito-core/src/render/lower/shift.rs b/crates/rito-core/src/render/lower/shift.rs new file mode 100644 index 000000000..f2fe7ef3a --- /dev/null +++ b/crates/rito-core/src/render/lower/shift.rs @@ -0,0 +1,123 @@ +//! Moves a finished list by whole device pixels, the last step before it is +//! encoded: a scrolled window paints and lowers in its chapter's +//! coordinates, where every edge and baseline rounds exactly as it does for +//! the whole chapter, and is then moved onto its own origin. Moving by whole +//! device pixels keeps every rounding where it fell, and doing it in f64 +//! before the f32 encoding keeps the window's coordinates small, so a +//! window far down a long chapter rasters as precisely as the first. +//! +//! Every position moves: points, rects, path vertices, a transform's +//! origin, a tile grid's origin. Displacements do not: a translate, a +//! shadow's offset, an icon piece's nudge and every size stay as they are. +//! Text runs, in CSS pixels, move by the same distance in CSS pixels. + +use std::sync::Arc; + +use super::super::commands::contract::{ReaderCluster, ReaderRect}; +use super::{DevicePath, DevicePoint, DeviceRect, Ground, PathOp, Primitive, PrimitiveList}; + +/// Moves every primitive of `list` by `dx`, `dy` device pixels. +pub(crate) fn shift(list: &mut PrimitiveList, dx: f64, dy: f64) { + let ratio = list.ratio; + for primitive in &mut list.commands { + shift_primitive(primitive, dx, dy, ratio); + } +} + +fn shift_primitive(primitive: &mut Primitive, dx: f64, dy: f64, ratio: f64) { + match primitive { + Primitive::PushState + | Primitive::PopState + | Primitive::Translate { .. } + | Primitive::Opacity { .. } => {} + Primitive::Transform { origin, .. } => origin.shift(dx, dy), + Primitive::ClipPath { path } => path.shift(dx, dy), + Primitive::FillRect { rect, ground, .. } => { + rect.shift(dx, dy); + ground.shift(dx, dy); + } + Primitive::FillPath { path, ground, .. } => { + path.shift(dx, dy); + ground.shift(dx, dy); + } + Primitive::StrokePath { path, .. } => path.shift(dx, dy), + Primitive::Shadow { + shape, clip_out, .. + } => { + shape.shift(dx, dy); + if let Some(clip_out) = clip_out { + clip_out.shift(dx, dy); + } + } + Primitive::DrawImage { dest, tiles, .. } => { + dest.shift(dx, dy); + if let Some(tiles) = tiles { + tiles.origin.shift(dx, dy); + } + } + Primitive::ImageFallback { block, pieces, .. } => { + block.shift(dx, dy); + for piece in pieces { + if let Some(clip) = &mut piece.clip { + clip.shift(dx, dy); + } + piece.path.shift(dx, dy); + } + } + Primitive::Text(run) | Primitive::Ruby(run) => { + let (dx, dy) = (dx / ratio, dy / ratio); + shift_css_rect(&mut run.rect, dx, dy); + run.clusters = run + .clusters + .iter() + .map(|cluster| ReaderCluster { + x: cluster.x + dx, + y: cluster.y + dy, + ..*cluster + }) + .collect::>(); + } + } +} + +fn shift_css_rect(rect: &mut ReaderRect, dx: f64, dy: f64) { + rect.x += dx; + rect.y += dy; +} + +impl DevicePoint { + fn shift(&mut self, dx: f64, dy: f64) { + self.x += dx; + self.y += dy; + } +} + +impl DeviceRect { + fn shift(&mut self, dx: f64, dy: f64) { + self.x += dx; + self.y += dy; + } +} + +impl DevicePath { + fn shift(&mut self, dx: f64, dy: f64) { + for op in &mut self.ops { + match op { + PathOp::MoveTo(point) | PathOp::LineTo(point) => point.shift(dx, dy), + PathOp::Arc { center, .. } | PathOp::Ellipse { center, .. } => { + center.shift(dx, dy); + } + PathOp::Rect(rect) => rect.shift(dx, dy), + PathOp::Close => {} + } + } + } +} + +impl Ground { + fn shift(&mut self, dx: f64, dy: f64) { + if let Self::Block(rect) = self { + rect.shift(dx, dy); + } + } +} diff --git a/crates/rito-core/src/render/lower/tests/mod.rs b/crates/rito-core/src/render/lower/tests/mod.rs index e57e5e65b..6dd0e4b46 100644 --- a/crates/rito-core/src/render/lower/tests/mod.rs +++ b/crates/rito-core/src/render/lower/tests/mod.rs @@ -1,4 +1,5 @@ mod parity; +mod shift; use std::f64::consts::{FRAC_PI_2, PI}; diff --git a/crates/rito-core/src/render/lower/tests/shift.rs b/crates/rito-core/src/render/lower/tests/shift.rs new file mode 100644 index 000000000..ab42a3fc3 --- /dev/null +++ b/crates/rito-core/src/render/lower/tests/shift.rs @@ -0,0 +1,247 @@ +//! A lowered list moved by whole device pixels: positions move, +//! displacements and sizes stay, text runs move in CSS pixels. + +use std::f64::consts::FRAC_PI_2; + +use super::super::{shift, IconPiece, ImageFallbackState, PrimitiveList}; +use super::*; + +#[test] +fn every_position_moves_and_every_displacement_stays() { + let path = || DevicePath { + ops: vec![ + PathOp::MoveTo(DevicePoint::new(1.0, 2.0)), + PathOp::LineTo(DevicePoint::new(3.0, 4.0)), + PathOp::Arc { + center: DevicePoint::new(5.0, 6.0), + rx: 7.0, + ry: 8.0, + start: 0.0, + sweep: FRAC_PI_2, + }, + PathOp::Ellipse { + center: DevicePoint::new(9.0, 10.0), + rx: 11.0, + ry: 12.0, + }, + PathOp::Rect(DeviceRect::new(13.0, 14.0, 15.0, 16.0)), + PathOp::Close, + ], + }; + let moved_path = || DevicePath { + ops: vec![ + PathOp::MoveTo(DevicePoint::new(-1.0, -38.0)), + PathOp::LineTo(DevicePoint::new(1.0, -36.0)), + PathOp::Arc { + center: DevicePoint::new(3.0, -34.0), + rx: 7.0, + ry: 8.0, + start: 0.0, + sweep: FRAC_PI_2, + }, + PathOp::Ellipse { + center: DevicePoint::new(7.0, -30.0), + rx: 11.0, + ry: 12.0, + }, + PathOp::Rect(DeviceRect::new(11.0, -26.0, 15.0, 16.0)), + PathOp::Close, + ], + }; + let piece = |path| IconPiece { + offset: DevicePoint::new(-1.1, 0.8), + clip: Some(path), + path: DevicePath { ops: vec![] }, + }; + let run = text_run(); + let mut list = PrimitiveList { + ratio: 2.0, + commands: vec![ + Primitive::Translate { dx: 3.0, dy: 4.0 }, + Primitive::Transform { + origin: DevicePoint::new(10.0, 20.0), + transforms: vec![DeviceTransform::Translate { dx: 1.0, dy: 2.0 }], + }, + Primitive::ClipPath { path: path() }, + Primitive::FillRect { + rect: DeviceRect::new(1.0, 2.0, 3.0, 4.0), + color: INK, + ground: Ground::Block(DeviceRect::new(5.0, 6.0, 7.0, 8.0)), + }, + Primitive::StrokePath { + path: path(), + width: 2.0, + color: INK, + cap: StrokeCap::Butt, + dash: Some(DashPattern { on: 3.0, off: 1.0 }), + }, + Primitive::Shadow { + shape: path(), + sigma: 2.0, + offset: DevicePoint::new(4.0, 5.0), + color: INK, + clip_out: Some(path()), + }, + Primitive::DrawImage { + src: "paper.png".to_owned(), + dest: DeviceRect::new(1.0, 2.0, 3.0, 4.0), + source_rect: Some(rect(0.0, 1.0, 2.0, 3.0)), + tiles: Some(TilePlan { + origin: DevicePoint::new(5.0, 6.0), + step_x: 7.0, + step_y: 8.0, + columns: 2, + rows: 3, + }), + }, + Primitive::ImageFallback { + src: "paper.png".to_owned(), + state: ImageFallbackState::Loading, + block: DeviceRect::new(1.0, 2.0, 3.0, 4.0), + block_ink: 0.1, + icon_ink: 0.2, + pieces: vec![piece(path())], + }, + Primitive::Text(run.clone()), + ], + }; + shift(&mut list, -2.0, -40.0); + + let mut moved_run = run.clone(); + moved_run.rect.x -= 1.0; + moved_run.rect.y -= 20.0; + moved_run.clusters = run + .clusters + .iter() + .map(|cluster| ReaderCluster { + x: cluster.x - 1.0, + y: cluster.y - 20.0, + ..*cluster + }) + .collect(); + assert_eq!( + list.commands, + vec![ + Primitive::Translate { dx: 3.0, dy: 4.0 }, + Primitive::Transform { + origin: DevicePoint::new(8.0, -20.0), + transforms: vec![DeviceTransform::Translate { dx: 1.0, dy: 2.0 }], + }, + Primitive::ClipPath { path: moved_path() }, + Primitive::FillRect { + rect: DeviceRect::new(-1.0, -38.0, 3.0, 4.0), + color: INK, + ground: Ground::Block(DeviceRect::new(3.0, -34.0, 7.0, 8.0)), + }, + Primitive::StrokePath { + path: moved_path(), + width: 2.0, + color: INK, + cap: StrokeCap::Butt, + dash: Some(DashPattern { on: 3.0, off: 1.0 }), + }, + Primitive::Shadow { + shape: moved_path(), + sigma: 2.0, + offset: DevicePoint::new(4.0, 5.0), + color: INK, + clip_out: Some(moved_path()), + }, + Primitive::DrawImage { + src: "paper.png".to_owned(), + dest: DeviceRect::new(-1.0, -38.0, 3.0, 4.0), + source_rect: Some(rect(0.0, 1.0, 2.0, 3.0)), + tiles: Some(TilePlan { + origin: DevicePoint::new(3.0, -34.0), + step_x: 7.0, + step_y: 8.0, + columns: 2, + rows: 3, + }), + }, + Primitive::ImageFallback { + src: "paper.png".to_owned(), + state: ImageFallbackState::Loading, + block: DeviceRect::new(-1.0, -38.0, 3.0, 4.0), + block_ink: 0.1, + icon_ink: 0.2, + pieces: vec![piece(moved_path())], + }, + Primitive::Text(moved_run), + ] + ); +} + +/// Moved by a whole number of CSS pixels within positive coordinates, a +/// list lowers to what the same commands lower to drawn there: every edge, +/// band and baseline rounds the same on either side of the move. (Into +/// negative coordinates a half rounds the other way — `f64::round` takes +/// it away from zero — which is why a window lowers in its chapter's +/// coordinates and moves after.) +#[test] +fn a_list_moved_by_whole_css_pixels_is_the_list_lowered_there() { + let commands = |dy: f64| { + let mut run = text(); + run.rect.y += dy; + run.clusters = run + .clusters + .iter() + .map(|cluster| crate::render::TextCluster { + y: cluster.y + dy, + ..*cluster + }) + .collect(); + vec![ + DisplayCommand::PushState, + DisplayCommand::ClipRect { + rect: rect(2.0, 3.3 + dy, 200.0, 300.0), + radius: None, + }, + block( + rect(10.3, 40.6 + dy, 120.4, 30.7), + ReaderBlockPaint { + box_shadows: vec![ReaderBoxShadow { + offset_x: 5.0, + offset_y: 5.0, + blur: 5.0, + spread: 3.0, + color: TRANSLUCENT, + inset: false, + }], + radius: Some(ReaderBlockRadius::Px(4.0)), + ..block_paint(Some(INK), Some(solid_border())) + }, + Some(widths(1.0, 2.0, 1.5, 0.5)), + ), + block( + rect(10.0, 90.25 + dy, 40.0, 20.0), + ReaderBlockPaint { + background: Some(ReaderBackgroundPaint { + color: None, + image: Some("paper.png".to_owned()), + size: None, + repeat: None, + position: None, + }), + ..block_paint(None, None) + }, + None, + ), + DisplayCommand::PaintText(run), + DisplayCommand::PaintImage { + src: "images/plate.png".to_owned(), + rect: rect(1.5, 120.0 + dy, 10.0, 20.0), + alt: None, + href: None, + source_rect: None, + }, + DisplayCommand::PopState, + ] + }; + for ratio in [1.0, 2.0, 3.0] { + let mut moved = lower(&commands(100.0), ratio, &sixteen_square).expect("lower"); + shift(&mut moved, 0.0, -20.0 * ratio); + let there = lower(&commands(80.0), ratio, &sixteen_square).expect("lower"); + assert_eq!(moved, there, "ratio {ratio}"); + } +} diff --git a/crates/rito-core/src/render/run_paint.rs b/crates/rito-core/src/render/run_paint.rs index 846dadde5..b48a31e2a 100644 --- a/crates/rito-core/src/render/run_paint.rs +++ b/crates/rito-core/src/render/run_paint.rs @@ -87,6 +87,18 @@ impl RunPaint { }) } + /// The paint emphasis marks set at `font_size` in `color` take beside + /// this run's glyphs: its family, weight and slant, nothing else. + pub(crate) fn for_marks( + &self, + font_size: f64, + color: crate::render::contract::ReaderColor, + ) -> Self { + let mut marks = self.for_ruby(font_size); + Arc::make_mut(&mut marks.data).color = color; + marks + } + /// The same paint drawn with another family. pub(crate) fn with_family(&self, family: std::sync::Arc) -> Self { if self.data.font.family == family { diff --git a/crates/rito-core/src/runtime.rs b/crates/rito-core/src/runtime.rs index d91a1dd2f..878dd8351 100644 --- a/crates/rito-core/src/runtime.rs +++ b/crates/rito-core/src/runtime.rs @@ -28,6 +28,7 @@ mod reader_session; mod resource; mod revision; mod revision_fonts; +mod scroll_strip; mod search; mod source_locator; mod spread; diff --git a/crates/rito-core/src/runtime/chapter_engine_session.rs b/crates/rito-core/src/runtime/chapter_engine_session.rs index 4de7d6b41..e692df672 100644 --- a/crates/rito-core/src/runtime/chapter_engine_session.rs +++ b/crates/rito-core/src/runtime/chapter_engine_session.rs @@ -106,6 +106,48 @@ impl<'a> ChapterEngineSession<'a> { self.backend.resolve_text_range(query) } + /// Where an anchored element starts in its page's display list drawn + /// at `ratio`. + pub(super) fn anchor_block_offset( + &self, + page_index: usize, + anchor: &str, + ratio: f64, + ) -> Option { + self.backend.anchor_block_offset(page_index, anchor, ratio) + } + + /// Where the content box a page's records are laid out in sits on the + /// page as drawn at `ratio`. + pub(super) fn record_frame( + &self, + page_index: usize, + ratio: f64, + ) -> Option { + self.backend.record_frame(page_index, ratio) + } + + /// A scrolled chapter's extent along the scroll axis. + pub(super) fn chapter_extent(&self, idref: &str) -> Option { + self.backend.chapter_extent(idref) + } + + /// A page's width and height. + pub(super) fn page_size(&self, page_index: usize) -> Option<(f64, f64)> { + self.backend.page_size(page_index) + } + + /// Where the line a source point sits on starts in its page's display + /// list drawn at `ratio`, for a scrolled window to be shown from. + pub(super) fn target_block_offset( + &self, + page_index: usize, + point: &super::page_artifact::PageArtifactSourcePoint, + ratio: f64, + ) -> Option { + self.backend.target_block_offset(page_index, point, ratio) + } + /// The caret at an exact address, with its geometry and source point. pub(super) fn text_caret( &self, diff --git a/crates/rito-core/src/runtime/chapter_engine_session/fragment/mod.rs b/crates/rito-core/src/runtime/chapter_engine_session/fragment/mod.rs index 991f26762..467397b06 100644 --- a/crates/rito-core/src/runtime/chapter_engine_session/fragment/mod.rs +++ b/crates/rito-core/src/runtime/chapter_engine_session/fragment/mod.rs @@ -43,7 +43,7 @@ use super::super::{ PageArtifactTextRangeFromPointsQuery, PageArtifactTextRangeFromPointsResolution, PageArtifactTextRangeQuery, PageArtifactTextRangeToPointQuery, PageArtifactTextSelectionGranularity, PageArtifactTextSelectionMovementQuery, - PageArtifactTextSelectionMovementResolution, + PageArtifactTextSelectionMovementResolution, PageRecordFrame, }, RuntimeRevision, }; @@ -93,6 +93,13 @@ impl<'a> FragmentChapterEngineSession<'a> { ratio: f64, ) -> crate::epub::EpubResult> { let config = &self.revision.layout_config; + if config.spread_mode == SpreadMode::Scrolled { + if let Some(window) = self.window_frame(spread_index, ratio)? { + return Ok(Some(window)); + } + // A page of a chapter paginated across the book's axis is a + // window shown whole, framed as a page. + } let spreads = build_spread_slots( self.layout.page_count(), self.layout.chapter_start_pages(), @@ -205,6 +212,30 @@ impl<'a> FragmentChapterEngineSession<'a> { Ok(Some(PageArtifactFrame { page_indexes, commands, + device_origin: (0.0, 0.0), + })) + } + + /// A scrolled revision's spread: one window, framed by its chapter's + /// strip, which says where the window sits in the chapter. `None` for + /// a page of a chapter paginated across the book's axis. + fn window_frame( + &self, + page_index: usize, + ratio: f64, + ) -> crate::epub::EpubResult> { + let Some((page, _)) = self.layout.page_with_chapter(page_index)? else { + return Ok(None); + }; + let Some(window) = page.window_frame(ratio).transpose()? else { + return Ok(None); + }; + let mut commands = FrameCommands::default(); + commands.push_page(window.commands); + Ok(Some(PageArtifactFrame { + page_indexes: vec![page_index], + commands, + device_origin: window.origin, })) } @@ -293,6 +324,61 @@ impl<'a> FragmentChapterEngineSession<'a> { } } + /// Where the line a source point sits on starts in its page's display + /// list drawn at `ratio`, from the page's block-start edge (its top, or + /// its right edge for vertical lines): the block offset a scrolled + /// window is shown from to put that line on the reading line, the + /// content box's block start in the viewport. A line's runs can start + /// at different block positions; the line starts at the first. `None` + /// when the page draws no run of the point. + pub(super) fn target_block_offset( + &self, + page_index: usize, + point: &PageArtifactSourcePoint, + ratio: f64, + ) -> Option { + let address = self.address_at_source_point(page_index..page_index + 1, point, false)?; + let artifact = self.artifact(page_index)?; + let start = artifact + .interaction_runs() + .iter() + .filter(|run| { + run.block_index == address.block_index && run.line_index == address.line_index + }) + .map(|run| run.y) + .reduce(f64::min)?; + self.layout.page(page_index)?.block_offset(start, ratio) + } + + /// Where the element `anchor` names starts in its page's display list + /// drawn at `ratio`, from the page's block-start edge. + pub(super) fn anchor_block_offset( + &self, + page_index: usize, + anchor: &str, + ratio: f64, + ) -> Option { + let page = self.layout.page(page_index)?; + page.block_offset(page.anchor_top(anchor)?, ratio) + } + + /// Where the content box a page's records are laid out in sits on the + /// page as drawn at `ratio`. + pub(super) fn record_frame(&self, page_index: usize, ratio: f64) -> Option { + Some(self.layout.page(page_index)?.record_frame(ratio)) + } + + /// A page's width and height: a scrolled window's height is its own + /// length, the last of a chapter's ending where the chapter does. + pub(super) fn page_size(&self, page_index: usize) -> Option<(f64, f64)> { + Some(self.layout.page(page_index)?.size()) + } + + /// A scrolled chapter's extent along the scroll axis. + pub(super) fn chapter_extent(&self, idref: &str) -> Option { + self.layout.chapter_extent(idref) + } + /// The caret address a durable source point lands on, searching the /// given pages in order. fn address_at_source_point( diff --git a/crates/rito-core/src/runtime/chapter_engine_session/fragment/movement.rs b/crates/rito-core/src/runtime/chapter_engine_session/fragment/movement.rs index 30091fb31..b2774ef24 100644 --- a/crates/rito-core/src/runtime/chapter_engine_session/fragment/movement.rs +++ b/crates/rito-core/src/runtime/chapter_engine_session/fragment/movement.rs @@ -12,7 +12,7 @@ use std::{ rc::Rc, }; -use crate::interaction::{plain_word_boundaries, TextSelectionBoundary, TextSelectionMovement}; +use crate::interaction::{plain_word_edges, TextSelectionBoundary, TextSelectionMovement}; use super::super::super::page_artifact::{FragmentPageArtifact, FragmentRunRecord}; @@ -251,15 +251,25 @@ fn step_word( ) -> Moved { let page = pages.page(focus.slot); let text = page.text(); - let boundaries = plain_word_boundaries(text, language); + let edges = plain_word_edges(text, language); + let boundaries: Vec = edges.iter().map(|(edge, _)| *edge).collect(); let offset = focus.offset as u32; let next = match step { - WordStep::Left => boundaries - .iter() + // A word step lands on the far edge of a word, past the spaces and + // punctuation before it, as Chromium's word movement does: the + // start of the previous word, or the end of the next one (the + // page's edge when no word is left that way). + WordStep::Left => edges + .windows(2) .rev() - .find(|edge| **edge < offset) - .copied(), - WordStep::Right => boundaries.iter().find(|edge| **edge > offset).copied(), + .find(|pair| pair[1].1 && pair[0].0 < offset) + .map(|pair| pair[0].0) + .or_else(|| (offset > 0).then_some(0)), + WordStep::Right => edges + .iter() + .find(|(edge, word)| *edge > offset && *word) + .map(|(edge, _)| *edge) + .or_else(|| boundaries.last().copied().filter(|end| *end > offset)), WordStep::NextStart => { // The start of the word after the current one: skip the edge // that closes the current segment, then any whitespace-only diff --git a/crates/rito-core/src/runtime/cleanup/revision/tests.rs b/crates/rito-core/src/runtime/cleanup/revision/tests.rs index 674797dd7..80eb835af 100644 --- a/crates/rito-core/src/runtime/cleanup/revision/tests.rs +++ b/crates/rito-core/src/runtime/cleanup/revision/tests.rs @@ -156,6 +156,7 @@ fn font_face() -> RuntimeRequiredFontFace { shape_fingerprint: "shape".to_owned(), byte_length: 123, source_order: 0, + features: Vec::new(), } } diff --git a/crates/rito-core/src/runtime/fragment_backend.rs b/crates/rito-core/src/runtime/fragment_backend.rs index dd51ccc77..632dedebd 100644 --- a/crates/rito-core/src/runtime/fragment_backend.rs +++ b/crates/rito-core/src/runtime/fragment_backend.rs @@ -17,14 +17,14 @@ use std::{ sync::Arc, }; -use rito_fragment::{BreakToken, CancelFlag}; +use rito_fragment::{BreakToken, CancelFlag, Fragment}; use crate::epub::{EpubError, EpubResult}; use crate::fragment_pagination::{ layout_chapter_page, paint_chapter_page, ChapterPaginator, FragmentChapterPage, }; use crate::fragment_paint::FragmentPaintContext; -use crate::layout::LayoutConfig; +use crate::layout::{LayoutConfig, SpreadMode}; use crate::render::{ contract::{ReaderBackgroundPaint, ReaderColor}, DisplayCommand, @@ -32,10 +32,12 @@ use crate::render::{ use super::fragment_frame::RuntimeFragmentEngine; use super::frame::{RuntimeChapterStyleTables, RuntimeRevisionInteractions}; -use super::page_artifact::{FragmentPageArtifact, PageArtifactSourceRunStart}; +use super::page_artifact::{FragmentPageArtifact, PageArtifactSourceRunStart, PageRecordFrame}; +use super::scroll_strip::ChapterStrip; use super::search::{SearchPageText, SearchPrebuiltRun, SearchPrebuiltRunSource}; use super::source_locator::PaginationTarget; use super::RuntimeDocument; +use crate::fragment_window::ScrollAxis; /// How many pages the table keeps materialized across all chapters. A /// materialized page holds its fragment tree and query artifact, and its @@ -74,6 +76,9 @@ pub(super) struct FragmentBackendChapter { pub(super) page_background: Option, /// The body's background image painted across the full page. pub(super) page_background_image: Option, + /// A scrolled chapter's extent along the scroll axis, in CSS pixels: + /// its margins and its strip. + pub(super) extent: Option, /// The chapter's own half of its rebuild inputs. style_tables: Rc, /// Intrinsic image sizes as the first build saw them. Recorded rather @@ -92,6 +97,28 @@ struct FragmentTableBuildInputs { /// The paint alias of every face the engine shapes with. faces: Rc<[std::sync::Arc]>, config: LayoutConfig, + /// The way a scrolled book's chapters follow one another. + axis: ScrollAxis, +} + +impl FragmentTableBuildInputs { + /// Whether a chapter is laid out as one strip: a scrolled chapter whose + /// blocks run along the book's axis. One that runs across it is + /// paginated, each of its pages one window along the book's axis. + fn strips(&self, tree: &rito_fragment::FormattingTree) -> bool { + self.config.spread_mode == SpreadMode::Scrolled + && ScrollAxis::of_chapter(crate::fragment_pagination::chapter_is_vertical(tree)) + == self.axis + } + + /// A scrolled window's length along the book's axis: a page's height, + /// or its width for a book that scrolls leftwards. + fn window_length(&self) -> f64 { + match self.axis { + ScrollAxis::Down => self.config.page_height, + ScrollAxis::Left => self.config.page_width, + } + } } impl std::fmt::Debug for FragmentTableBuildInputs { @@ -111,6 +138,9 @@ impl std::fmt::Debug for FragmentTableBuildInputs { pub(super) struct MaterializedChapter { paint: ChapterPaintSource, pages: Vec, + /// The strip a scrolled chapter's pages are windows of, which paints + /// them. + strip: Option>, /// The page box the pages' query artifacts are built in. page_width: f64, page_height: f64, @@ -145,6 +175,15 @@ impl MaterializedChapter { if entry.has_text || entry.text_root.get().is_some() { return Ok(()); } + if let Some(strip) = &self.strip { + // A window is laid out with its text as its strip is, around it. + let text = strip.window_text(&self.engine, page)?; + let _ = entry + .text_root + .set(strip.window_text_page_root(&text, page)); + let _ = entry.window_text.set(text); + return Ok(()); + } let (root, end) = layout_chapter_page( &self.engine.engine, &self.paint.built.tree, @@ -176,6 +215,42 @@ impl MaterializedChapter { .expect("a page's text is laid out before the page is read") } + /// A scrolled chapter's window framed at `ratio` by its strip, cached + /// as a page's commands are, and where it sits in the chapter. + fn window_frame( + &self, + strip: &ChapterStrip, + page: usize, + ratio: f64, + ) -> EpubResult { + let origin = strip.windows().device_origin(page, ratio); + let key = ratio.to_bits(); + let entry = &self.pages[page]; + if let Some((_, commands)) = entry + .paint_cache + .borrow() + .iter() + .find(|(cached, _)| *cached == key) + { + return Ok(WindowFrame { + commands: Arc::clone(commands), + origin, + }); + } + let text = entry + .window_text + .get() + .expect("a window's text is laid out before the window is painted"); + let (commands, _) = strip.window_frame(text, page..page + 1, ratio)?; + let commands = Arc::new(commands); + let mut cache = entry.paint_cache.borrow_mut(); + if cache.len() >= 2 { + cache.remove(0); + } + cache.push((key, Arc::clone(&commands))); + Ok(WindowFrame { commands, origin }) + } + /// The page's paint commands at `ratio` device pixels per CSS pixel, /// painted on first use and cached: pagination geometry is identical /// at every ratio, only the glyph baselines' device rounding moves. @@ -203,7 +278,10 @@ impl MaterializedChapter { node_paints: Some(&paint.built.node_paints), image_border_paints: Some(&paint.built.image_border_paints), list_markers: Some(&paint.built.list_markers), - ruby_annotation_runs: Some(&paint.built.ruby_annotation_runs), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&paint.built.ruby_annotation_runs), + emphasis: Some(&paint.built.emphasis_marks), + }, vertical_frame: None, flow_item_sources: Some(&paint.built.flow_item_sources), ratio, @@ -219,6 +297,14 @@ impl MaterializedChapter { } } +/// A scrolled chapter's window painted at one ratio: its frame in the +/// chapter's coordinates and where its top-left corner sits in the chapter, +/// in device pixels. +pub(super) struct WindowFrame { + pub(super) commands: Arc>, + pub(super) origin: (f64, f64), +} + /// One page of a materialized chapter. Holding it keeps that chapter /// materialized, so a caller can read a page's geometry and paint it /// without the chapter being evicted underneath. @@ -231,7 +317,19 @@ pub(super) struct FragmentPageRef { impl FragmentPageRef { /// The page's width and height, without building its query artifact. pub(super) fn size(&self) -> (f64, f64) { - (self.chapter.page_width, self.chapter.page_height) + match &self.chapter.strip { + // A scrolled chapter's last window ends where the chapter does, + // below it or left of it. + Some(strip) => { + let range = strip.windows().range(self.page); + let length = range.end - range.start; + match strip.windows().axis() { + ScrollAxis::Down => (self.chapter.page_width, length), + ScrollAxis::Left => (length, self.chapter.page_height), + } + } + None => (self.chapter.page_width, self.chapter.page_height), + } } /// The page's query artifact as a standalone handle: it carries the @@ -245,6 +343,88 @@ impl FragmentPageRef { pub(super) fn commands_for(&self, ratio: f64) -> EpubResult>> { self.chapter.commands_for(self.page, ratio) } + + /// Where the element `anchor` names starts on the page, in its content + /// box; `None` when the page holds none of it. + pub(super) fn anchor_top(&self, anchor: &str) -> Option { + fn find( + fragment: &Fragment, + parent_y: f64, + anchors: &BTreeMap, + anchor: &str, + ) -> Option { + let top = parent_y + fragment.rect().y; + if anchors + .get(&fragment.source().0) + .is_some_and(|name| name == anchor) + { + return Some(top); + } + match fragment { + Fragment::Box(inner) => inner + .children + .iter() + .find_map(|child| find(child, top, anchors, anchor)), + _ => None, + } + } + find( + self.chapter.text_tree(self.page), + 0.0, + &self.chapter.paint.built.node_anchors, + anchor, + ) + } + + /// Where the content box the page's records are laid out in sits on + /// the page as drawn at `ratio`: at the page's margins, a vertical-rl + /// page's columns in from its right one; a scrolled window's moved as + /// its pixels are, by the device rows it is drawn from. + pub(super) fn record_frame(&self, ratio: f64) -> PageRecordFrame { + let paint = &self.chapter.paint; + let (start, (shift_x, shift_y)) = match &self.chapter.strip { + Some(strip) => { + let (x, y) = strip.windows().device_origin(self.page, ratio); + ( + strip.windows().range(self.page).start, + (x / ratio, y / ratio), + ) + } + None => (0.0, (0.0, 0.0)), + }; + let (left, top) = paint.origin; + if paint.vertical { + PageRecordFrame::VerticalRl { + right: left + paint.content_width - start - shift_x, + top: top - shift_y, + } + } else { + PageRecordFrame::HorizontalTb { + left: left - shift_x, + top: top + start - shift_y, + } + } + } + + /// Where record block position `y` of a scrolled window sits in the + /// window drawn at `ratio`, from its block-start edge (its top, or its + /// right edge for a chapter that runs leftwards). `None` for a page, + /// which is shown whole. + pub(super) fn block_offset(&self, y: f64, ratio: f64) -> Option { + let strip = self.chapter.strip.as_ref()?; + let rows = strip.windows().device_rows(self.page, ratio); + Some( + self.record_frame(ratio) + .block_offset(y, (rows.end - rows.start) / ratio), + ) + } + + /// A scrolled chapter's window framed at `ratio`, in the chapter's + /// coordinates; `None` for a page of a paginated chapter. + pub(super) fn window_frame(&self, ratio: f64) -> Option> { + let strip = self.chapter.strip.as_ref()?; + Some(self.chapter.window_frame(strip, self.page, ratio)) + } } /// One page: its query artifact and the commands that paint its content @@ -265,6 +445,10 @@ struct FragmentBackendPage { end: Option, /// The page laid out again with its text, when pagination left it out. text_root: std::cell::OnceCell, + /// What paints a scrolled chapter's window, laid out again with its + /// text when the window is read: the strip's fragments that reach it, + /// in the strip's coordinates. + window_text: std::cell::OnceCell, /// Paint commands per device ratio (as f64 bits). A reader draws at /// one ratio at a time and a zoom or density change is rare, so the /// cache keeps the two most recent. @@ -480,6 +664,7 @@ impl FragmentBuiltLayout { start: page.start, end: rest.resumes_at().cloned(), text_root: std::cell::OnceCell::new(), + window_text: std::cell::OnceCell::new(), paint_cache: RefCell::new(Vec::new()), }); } @@ -512,6 +697,16 @@ impl FragmentBuiltLayout { let Ok(chapter) = self.materialize(chapter_index) else { break; }; + if let Some(strip) = &chapter.strip { + // Every window's text at once, from one layout of the strip. + let Ok(roots) = strip.window_record_roots(&chapter.engine) else { + return records; + }; + for (offset, root) in roots.iter().enumerate() { + records.push(search_record(base + offset, root, &chapter.paint)); + } + continue; + } for offset in 0..chapter.pages.len() { if chapter.ensure_text(offset).is_err() { return records; @@ -576,6 +771,18 @@ impl FragmentBuiltLayout { self.chapters().find(|(chapter, _)| chapter.idref == idref) } + /// A scrolled chapter's extent along the book's axis: its strip's, or, + /// for a chapter paginated across that axis, its pages one window + /// each. `None` in a revision of pages. + pub(super) fn chapter_extent(&self, idref: &str) -> Option { + let (chapter, _) = self.chapter(idref)?; + chapter.extent.or_else(|| { + let build = self.build.as_ref()?; + (build.config.spread_mode == SpreadMode::Scrolled) + .then(|| chapter.page_count as f64 * build.window_length()) + }) + } + /// The page, materializing its chapter if it is not already. `None` /// for a page index outside the table or a chapter that no longer /// rebuilds; `page_with_chapter` reports the rebuild's reason. @@ -690,6 +897,7 @@ impl FragmentBuiltLayout { &record.idref, &record.image_dimensions, )?; + let strip = build.strips(&built.tree); let paginated = paginate_built_chapter( &build.engine, Rc::clone(&build.faces), @@ -697,6 +905,7 @@ impl FragmentBuiltLayout { &build.config, &record.idref, false, + strip, ) .map_err(EpubError::new)?; if paginated.pages.len() != record.page_count { @@ -716,6 +925,51 @@ impl FragmentBuiltLayout { Rc::clone(&build.engine), )) } + + /// One chapter laid out as a continuous strip with its text from the + /// inputs the table recorded, on the engine that paginated it. + #[cfg(test)] + pub(super) fn chapter_strip( + &self, + chapter_index: usize, + ) -> EpubResult { + self.chapter_strip_laid_out(chapter_index, true) + } + + /// The engine the table was paginated on, which lays a window's text out. + #[cfg(test)] + pub(super) fn strip_engine(&self) -> Option<&RuntimeFragmentEngine> { + self.build.as_ref().map(|build| build.engine.as_ref()) + } + + /// [`Self::chapter_strip`], with its text or without as `line_text` says. + #[cfg(test)] + pub(super) fn chapter_strip_laid_out( + &self, + chapter_index: usize, + line_text: bool, + ) -> EpubResult { + let record = &self.chapters[chapter_index]; + let build = self.build.as_ref().ok_or_else(|| { + EpubError::new(format!( + "chapter {} has no build inputs recorded", + record.idref + )) + })?; + let built = build.engine.chapter_tree( + &build.prepared, + &record.style_tables, + &record.idref, + &record.image_dimensions, + )?; + super::scroll_strip::ChapterStrip::lay_out( + &build.engine, + Rc::clone(&build.faces), + built, + &build.config, + line_text, + ) + } } /// A paginated chapter's pages, each with its query artifact, numbered @@ -753,12 +1007,14 @@ fn materialized_chapter( start: page.start, end, text_root: std::cell::OnceCell::new(), + window_text: std::cell::OnceCell::new(), paint_cache: RefCell::new(Vec::new()), }) .collect(); MaterializedChapter { paint: paginated.paint, pages, + strip: paginated.strip, page_width: config.page_width, page_height: config.page_height, engine, @@ -784,7 +1040,84 @@ pub(super) struct FragmentTableBuild { struct TableChapter { record: FragmentBackendChapter, built: Rc, - paginator: ChapterPaginator, + pages: TableChapterPages, +} + +/// Where a whole-book page table takes a chapter's next page from. +enum TableChapterPages { + /// A chapter paginating one page at a time. + Paginated(ChapterPaginator), + /// A scrolled chapter warming up: paginated a page at a time with its + /// pages dropped, so the strip laid out when it is through reads what + /// their shaping left behind. A chapter's strip is one layout; warmed, + /// it costs a fraction of a step, cold it can cost many. + Warming(ChapterPaginator), + /// A scrolled chapter's windows, its strip laid out once it warmed, + /// and the strip's extent with its margins. + Windows { + roots: std::collections::VecDeque, + extent: f64, + }, +} + +/// What one step of a table's chapter gave. +enum TableStep { + Page(Box), + /// A page of warming, which the table does not keep. + Warmed, +} + +impl TableChapterPages { + /// The chapter's next step, or `None` once it has given every page. + fn step( + &mut self, + build: &FragmentTableBuildInputs, + built: &Rc, + ) -> EpubResult> { + match self { + Self::Paginated(paginator) => Ok(paginator + .next_page(&build.engine.engine, &built.tree, &CancelFlag::new())? + .map(|page| TableStep::Page(Box::new(page)))), + Self::Warming(paginator) => { + paginator.next_page(&build.engine.engine, &built.tree, &CancelFlag::new())?; + if paginator.is_done() { + let strip = ChapterStrip::lay_out( + &build.engine, + Rc::clone(&build.faces), + Rc::clone(built), + &build.config, + false, + )?; + *self = Self::Windows { + roots: (0..strip.windows().count()) + .map(|window| strip.window_page_root(window)) + .collect(), + extent: strip.windows().extent(), + }; + } + Ok(Some(TableStep::Warmed)) + } + Self::Windows { roots, .. } => Ok(roots + .pop_front() + .map(|root| TableStep::Page(Box::new(FragmentChapterPage { root, start: None })))), + } + } + + fn is_done(&self) -> bool { + match self { + Self::Paginated(paginator) => paginator.is_done(), + Self::Warming(_) => false, + Self::Windows { roots, .. } => roots.is_empty(), + } + } + + /// A scrolled chapter's extent once its strip is laid out. + fn extent(&self) -> Option { + match self { + Self::Windows { extent, .. } => Some(*extent), + Self::Paginated(_) | Self::Warming(_) => None, + } + } } impl FragmentTableBuild { @@ -868,13 +1201,9 @@ impl RuntimeDocument { return Ok(false); } let chapter = table.current.as_mut().expect("a chapter is under way"); - let page = chapter - .paginator - .next_page( - &table.build.engine.engine, - &chapter.built.tree, - &CancelFlag::new(), - ) + let step = chapter + .pages + .step(&table.build, &chapter.built) .map_err(|error| { format!( "chapter {} pagination: {}", @@ -882,7 +1211,10 @@ impl RuntimeDocument { error.message() ) })?; - if let Some(page) = page { + if let Some(TableStep::Warmed) = step { + left -= 1; + } + if let Some(TableStep::Page(page)) = step { collect_page_anchors( &page.root, &chapter.built.node_anchors, @@ -900,8 +1232,9 @@ impl RuntimeDocument { chapter.record.page_count += 1; left -= 1; } - if chapter.paginator.is_done() { - if let Some(done) = table.current.take() { + if chapter.pages.is_done() { + if let Some(mut done) = table.current.take() { + done.record.extent = done.pages.extent(); table.chapters.push(done.record); } } @@ -931,7 +1264,15 @@ impl RuntimeDocument { if content_width <= 0.0 || content_height <= 0.0 { return Err("page content box is empty".to_owned()); } + // Without its text, as a whole-book table paginates: each page is + // dropped once its anchors and run starts are taken. A scrolled + // chapter warms up the same way before its strip is laid out. let paginator = ChapterPaginator::new(&built.tree, content_width, content_height, false); + let pages = if build.strips(&built.tree) { + TableChapterPages::Warming(paginator) + } else { + TableChapterPages::Paginated(paginator) + }; Ok(TableChapter { record: FragmentBackendChapter { idref, @@ -939,11 +1280,12 @@ impl RuntimeDocument { block_count: built.tree.node(built.tree.root()).children.len(), page_background: built.page_background, page_background_image: built.page_background_image.clone(), + extent: None, style_tables: Rc::clone(style_tables), image_dimensions, }, built, - paginator, + pages, }) } @@ -1023,6 +1365,7 @@ impl RuntimeDocument { let source_index = self.source_chapter_index(&idref); let starts_from = Rc::clone(&built); // Without its text: the page shown is laid out with it when read. + let strip = build.strips(&built.tree); let paginated = paginate_built_chapter_until( &build.engine, Rc::clone(&build.faces), @@ -1030,6 +1373,7 @@ impl RuntimeDocument { config, &idref, false, + strip, |page, index, resumes_at| { let starts = FragmentPageArtifact::source_run_starts(index, &page.root, &starts_from); @@ -1059,6 +1403,10 @@ impl RuntimeDocument { block_count, page_background: materialized.paint.built.page_background, page_background_image: materialized.paint.built.page_background_image.clone(), + extent: materialized + .strip + .as_ref() + .map(|strip| strip.windows().extent()), style_tables: Rc::clone(&style_tables), image_dimensions, }; @@ -1089,6 +1437,7 @@ impl RuntimeDocument { .ok_or_else(|| "no fragment engine (no pinned faces)".to_owned())?; Ok(FragmentTableBuildInputs { prepared, + axis: ScrollAxis::of_book(self.document.package.page_progression), faces: engine .engine .inline() @@ -1113,6 +1462,8 @@ struct PaginatedChapter { line_text: bool, paint: ChapterPaintSource, block_count: usize, + /// The strip a scrolled chapter's pages are windows of. + strip: Option>, } /// The pagination half of a chapter build: lays a bridged formatting tree @@ -1124,15 +1475,25 @@ fn paginate_built_chapter( config: &LayoutConfig, idref: &str, line_text: bool, + strip: bool, ) -> Result { - paginate_built_chapter_until(engine, faces, built, config, idref, line_text, |_, _, _| { - false - }) + paginate_built_chapter_until( + engine, + faces, + built, + config, + idref, + line_text, + strip, + |_, _, _| false, + ) } /// [`paginate_built_chapter`], stopping after the first page `settled` /// accepts — given the page, its index and where the chapter resumes after -/// it — and keeping where the rest resumes. +/// it — and keeping where the rest resumes. A chapter laid out as one +/// `strip` takes its windows for its pages. +#[allow(clippy::too_many_arguments)] fn paginate_built_chapter_until( engine: &RuntimeFragmentEngine, faces: Rc<[std::sync::Arc]>, @@ -1140,8 +1501,12 @@ fn paginate_built_chapter_until( config: &LayoutConfig, idref: &str, line_text: bool, + strip: bool, mut settled: impl FnMut(&FragmentChapterPage, usize, Option<&BreakToken>) -> bool, ) -> Result { + if strip { + return strip_built_chapter(engine, faces, built, config, idref, settled); + } let content_width = config.page_width - config.margin_left - config.margin_right; let content_height = config.page_height - config.margin_top - config.margin_bottom; if content_width <= 0.0 || content_height <= 0.0 { @@ -1176,6 +1541,7 @@ fn paginate_built_chapter_until( content_height, origin: (config.margin_left, config.margin_top), }, + strip: None, }) } @@ -1233,6 +1599,48 @@ fn collect_page_anchors( } } +/// [`paginate_built_chapter_until`] for a scrolled layout: the chapter laid +/// out as one strip, its pages the strip's windows. `settled` sees every +/// window, as it sees every page pagination makes, but a strip is laid out +/// whole, so nothing is left to resume. The strip is laid out without its +/// text whatever the pages were asked for: a window's is laid out around it +/// when the window is read. +fn strip_built_chapter( + engine: &RuntimeFragmentEngine, + faces: Rc<[std::sync::Arc]>, + built: Rc, + config: &LayoutConfig, + idref: &str, + mut settled: impl FnMut(&FragmentChapterPage, usize, Option<&BreakToken>) -> bool, +) -> Result { + let strip = ChapterStrip::lay_out(engine, Rc::clone(&faces), Rc::clone(&built), config, false) + .map_err(|error| format!("chapter {idref} strip: {}", error.message()))?; + let pages: Vec = (0..strip.windows().count()) + .map(|window| FragmentChapterPage { + root: strip.window_page_root(window), + start: None, + }) + .collect(); + for (index, page) in pages.iter().enumerate() { + settled(page, index, None); + } + Ok(PaginatedChapter { + pages, + rest: None, + line_text: false, + block_count: built.tree.node(built.tree.root()).children.len(), + paint: ChapterPaintSource { + vertical: strip.windows().axis() == ScrollAxis::Left, + built, + faces, + content_width: config.content_width(), + content_height: config.content_height(), + origin: (config.margin_left, config.margin_top), + }, + strip: Some(Rc::new(strip)), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1244,6 +1652,7 @@ mod tests { block_count: 1, page_background: None, page_background_image: None, + extent: None, style_tables: Rc::new(RuntimeChapterStyleTables { layout: rito_style_contract::LayoutStyleTable::new(0), inline: rito_style_contract::InlineStyleTable::new(0), diff --git a/crates/rito-core/src/runtime/page_artifact.rs b/crates/rito-core/src/runtime/page_artifact.rs index b7c92bb9b..b3839c84f 100644 --- a/crates/rito-core/src/runtime/page_artifact.rs +++ b/crates/rito-core/src/runtime/page_artifact.rs @@ -5,9 +5,11 @@ //! backend's box, line, or fragment tree directly. mod fragment; +mod frame; mod interaction; pub(in crate::runtime) use fragment::{FragmentPageArtifact, FragmentRunRecord}; +pub(in crate::runtime) use frame::{PageRecordFrame, PageWritingMode}; use std::sync::Arc; @@ -21,6 +23,11 @@ pub(super) use interaction::*; pub(super) struct PageArtifactFrame { pub(super) page_indexes: Vec, pub(super) commands: FrameCommands, + /// Where a scrolled window's top-left corner sits in its chapter, in + /// device pixels: the window paints in the chapter's coordinates, where + /// every edge and baseline rounds as it does for the whole chapter, and + /// moves back by this much once lowered. Zero for a spread of pages. + pub(super) device_origin: (f64, f64), } /// A spread's display commands, in paint order: the frame's own around diff --git a/crates/rito-core/src/runtime/page_artifact/fragment.rs b/crates/rito-core/src/runtime/page_artifact/fragment.rs index 6b015bf76..313aed843 100644 --- a/crates/rito-core/src/runtime/page_artifact/fragment.rs +++ b/crates/rito-core/src/runtime/page_artifact/fragment.rs @@ -1157,6 +1157,7 @@ mod tests { node_tags: BTreeMap::new(), list_markers: BTreeMap::new(), ruby_annotation_runs: BTreeMap::new(), + emphasis_marks: BTreeMap::new(), degradations: Vec::new(), } } diff --git a/crates/rito-core/src/runtime/page_artifact/frame.rs b/crates/rito-core/src/runtime/page_artifact/frame.rs new file mode 100644 index 000000000..7379add13 --- /dev/null +++ b/crates/rito-core/src/runtime/page_artifact/frame.rs @@ -0,0 +1,164 @@ +//! Where a page's records land on the page as it is drawn. +//! +//! Records are logical: x runs along a line (the inline axis) and y across +//! lines (the block axis), from the content box's start, the way the page +//! was laid out. A horizontal page draws them as laid out; a vertical-rl +//! page was laid out in the swapped frame and paints each line as a column +//! in from the content box's right edge, so its records turn the same way +//! where they leave the engine. + +use super::PageArtifactRect; + +/// The writing mode a page's lines were laid out in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::runtime) enum PageWritingMode { + HorizontalTb, + VerticalRl, +} + +/// Where the content box a page's records are laid out in sits on the page +/// as drawn. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::runtime) enum PageRecordFrame { + /// Lines run rightwards from `left` and stack downwards from `top`. + HorizontalTb { left: f64, top: f64 }, + /// Lines run downwards from `top` and stack leftwards from `right`. + VerticalRl { right: f64, top: f64 }, +} + +impl PageRecordFrame { + pub(in crate::runtime) fn writing_mode(self) -> PageWritingMode { + match self { + Self::HorizontalTb { .. } => PageWritingMode::HorizontalTb, + Self::VerticalRl { .. } => PageWritingMode::VerticalRl, + } + } + + /// The frame moved by `dx` across the drawing: a page in a spread's + /// second slot. + pub(in crate::runtime) fn moved_right(self, dx: f64) -> Self { + match self { + Self::HorizontalTb { left, top } => Self::HorizontalTb { + left: left + dx, + top, + }, + Self::VerticalRl { right, top } => Self::VerticalRl { + right: right + dx, + top, + }, + } + } + + /// A record's rect as drawn. + pub(in crate::runtime) fn rect(self, rect: PageArtifactRect) -> PageArtifactRect { + match self { + Self::HorizontalTb { left, top } => PageArtifactRect { + x: left + rect.x, + y: top + rect.y, + ..rect + }, + Self::VerticalRl { right, top } => PageArtifactRect { + x: right - rect.y - rect.height, + y: top + rect.x, + width: rect.height, + height: rect.width, + }, + } + } + + /// A drawn point in record coordinates. + pub(in crate::runtime) fn point(self, x: f64, y: f64) -> (f64, f64) { + match self { + Self::HorizontalTb { left, top } => (x - left, y - top), + Self::VerticalRl { right, top } => (y - top, right - x), + } + } + + /// A caret as drawn: the segment across the line at record inline + /// position `x`, from block position `y` for `height`, as a rect with no + /// extent along the line. + pub(in crate::runtime) fn caret(self, x: f64, y: f64, height: f64) -> PageArtifactRect { + self.rect(PageArtifactRect { + x, + y, + width: 0.0, + height, + }) + } + + /// Where record block position `y` sits from the block-start edge of a + /// drawing `extent` long along the block axis: its top, or its right + /// edge for vertical lines. + pub(in crate::runtime) fn block_offset(self, y: f64, extent: f64) -> f64 { + match self { + Self::HorizontalTb { top, .. } => top + y, + Self::VerticalRl { right, .. } => extent - right + y, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const RECT: PageArtifactRect = PageArtifactRect { + x: 10.0, + y: 30.0, + width: 48.0, + height: 20.0, + }; + + #[test] + fn a_horizontal_frame_moves_records_onto_the_content_box() { + let frame = PageRecordFrame::HorizontalTb { + left: 24.0, + top: 32.0, + }; + assert_eq!( + frame.rect(RECT), + PageArtifactRect { + x: 34.0, + y: 62.0, + ..RECT + } + ); + assert_eq!(frame.point(34.0, 62.0), (10.0, 30.0)); + assert_eq!(frame.block_offset(30.0, 640.0), 62.0); + } + + #[test] + fn a_vertical_frame_turns_lines_into_columns_in_from_its_right_edge() { + let frame = PageRecordFrame::VerticalRl { + right: 396.0, + top: 24.0, + }; + // Block 30..50 in from the right edge, inline 10..58 down from the + // top: a column 20 wide whose right edge is 30 left of 396. + let drawn = frame.rect(RECT); + assert_eq!( + drawn, + PageArtifactRect { + x: 346.0, + y: 34.0, + width: 20.0, + height: 48.0, + } + ); + // The point a record's corner draws at reads back to that corner. + assert_eq!(frame.point(drawn.x + drawn.width, drawn.y), (10.0, 30.0)); + // A caret runs across its column, with no height along it. + assert_eq!( + frame.caret(10.0, 30.0, 20.0), + PageArtifactRect { + x: 346.0, + y: 34.0, + width: 20.0, + height: 0.0, + } + ); + // From the right edge of a 420 wide drawing: its 24 margin, then + // the 30 in from the content box. + assert_eq!(frame.block_offset(30.0, 420.0), 54.0); + assert_eq!(frame.moved_right(440.0).rect(RECT).x, 786.0); + } +} diff --git a/crates/rito-core/src/runtime/pinned_font_policy.rs b/crates/rito-core/src/runtime/pinned_font_policy.rs index 0e0248b96..d17823759 100644 --- a/crates/rito-core/src/runtime/pinned_font_policy.rs +++ b/crates/rito-core/src/runtime/pinned_font_policy.rs @@ -153,6 +153,7 @@ impl RuntimePinnedFontPolicy { shape_fingerprint: face.summary.shape_fingerprint.clone(), byte_length: face.bytes.len(), source_order: first_order + index, + features: Vec::new(), }) .collect() } diff --git a/crates/rito-core/src/runtime/reader_session.rs b/crates/rito-core/src/runtime/reader_session.rs index 4192bd3e4..0180d8da4 100644 --- a/crates/rito-core/src/runtime/reader_session.rs +++ b/crates/rito-core/src/runtime/reader_session.rs @@ -62,7 +62,7 @@ pub use wire::{ encode_reader_font_names, READER_FONT_CATALOG_WIRE_MAGIC, READER_FONT_NAMES_WIRE_MAGIC, }; -pub const READER_PROTOCOL_VERSION: u32 = 7; +pub const READER_PROTOCOL_VERSION: u32 = 9; pub const READER_PUBLICATION_TOC_DEPTH_MAX: u32 = 64; pub const READER_PUBLICATION_TOC_ITEM_MAX: u32 = 100_000; pub const READER_PUBLICATION_WIRE_BYTES_MAX: u64 = 16 * 1024 * 1024; @@ -82,8 +82,12 @@ mod layout_tests; #[cfg(test)] mod pagination_tests; #[cfg(test)] +mod scrolled_tests; +#[cfg(test)] mod shard_tests; #[cfg(test)] mod tests; #[cfg(test)] mod typography_tests; +#[cfg(test)] +mod vertical_tests; diff --git a/crates/rito-core/src/runtime/reader_session/artifact.rs b/crates/rito-core/src/runtime/reader_session/artifact.rs index 5e36afbf2..2b7e7f6f3 100644 --- a/crates/rito-core/src/runtime/reader_session/artifact.rs +++ b/crates/rito-core/src/runtime/reader_session/artifact.rs @@ -2,12 +2,12 @@ use std::collections::BTreeSet; use crate::{ epub::parse_font_family_list, - layout::LayoutConfig, - render::{encode_reader_primitive_list, lower}, + layout::SpreadMode, + render::{encode_reader_primitive_list, lower, shift}, runtime::{ page_artifact::{ PageArtifactFrame, PageArtifactRect, PageArtifactSemanticNode, - PageArtifactSemanticRole, PageArtifactTarget, + PageArtifactSemanticRole, PageArtifactSourcePoint, PageArtifactTarget, PageRecordFrame, }, page_target::PageTargetClass, RuntimeChapterLocalRevisionHandle, RuntimeDocument, RuntimePageTargetKind, RuntimeRevision, @@ -18,10 +18,11 @@ use crate::{ use super::{ convert::{reader_locator, u32_from_usize, u64_from_usize}, - ReaderArtifact, ReaderDisplayList, ReaderError, ReaderErrorKind, ReaderFontRef, ReaderHitEntry, - ReaderHitKind, ReaderNavigation, ReaderPage, ReaderPageRecords, ReaderRect, ReaderResourceKind, - ReaderResourceRef, ReaderSemanticNode, ReaderSemanticRole, ReaderTextRenderingProfile, - ReaderTextRunOffset, READER_CAPABILITY_PROFILE_STRING_TEXT, READER_PROTOCOL_VERSION, + ReaderArtifact, ReaderDisplayList, ReaderError, ReaderErrorKind, ReaderFontFeature, + ReaderFontRef, ReaderHitEntry, ReaderHitKind, ReaderNavigation, ReaderPage, ReaderPageRecords, + ReaderRect, ReaderResourceKind, ReaderResourceRef, ReaderSemanticNode, ReaderSemanticRole, + ReaderTextRenderingProfile, ReaderTextRunOffset, READER_CAPABILITY_PROFILE_STRING_TEXT, + READER_PROTOCOL_VERSION, }; #[derive(Debug, Clone)] @@ -141,21 +142,29 @@ fn build_reader_artifact_from_revision( let painted_digest = painted_digest(frame.commands.iter()); // The display list the artifact carries is the frame lowered to the // host's device grid: every raster decision resolved here, the host - // only blits. - let lowered = lower(&frame.commands, render_ratio, &|href| { + // only blits. A scrolled window then moves onto its own origin. + let mut lowered = lower(&frame.commands, render_ratio, &|href| { document.image_size(href) }) .map_err(engine_error)?; + let (x, y) = frame.device_origin; + if (x, y) != (0.0, 0.0) { + shift(&mut lowered, -x, -y); + } let encoded = encode_reader_primitive_list(&lowered).map_err(engine_error)?; let config = &revision.layout_config; + let session = revision.chapter_engine_session(); let pages = frame .page_indexes .iter() .map(|page_index| { + let (width, height) = session + .page_size(*page_index) + .unwrap_or((config.page_width, config.page_height)); Ok(ReaderPage { page_index: u32_from_usize(*page_index, "page index")?, - width: config.page_width, - height: config.page_height, + width, + height, }) }) .collect::, ReaderError>>()?; @@ -212,6 +221,13 @@ fn build_reader_artifact_from_revision( .is_absolute_coordinate_space() .then(|| u32_from_usize(revision.extent.page_count, "book page count")) .transpose()?, + target_block_offset: scrolled_block_offset( + revision, + target.local_page_index, + &target.locator, + target.matched_by, + render_ratio, + ), navigation, text_profile: ReaderTextRenderingProfile::PlatformStringRuns, display_list: ReaderDisplayList { @@ -229,6 +245,44 @@ fn build_reader_artifact_from_revision( }) } +/// Where the line a scrolled artifact's locator lands on starts in its +/// window's display list: the line a source point or range starts on, or +/// the element an anchor names. `None` for a spread of pages, and for a +/// locator that names a window rather than a place in it — a progression +/// is a position among a chapter's windows, as it is among its pages. +pub(super) fn scrolled_block_offset( + revision: &RuntimeRevision, + page: usize, + locator: &RuntimeSourceLocator, + matched_by: RuntimeSourceLocatorMatchedBy, + ratio: f64, +) -> Option { + if revision.layout_config.spread_mode != SpreadMode::Scrolled { + return None; + } + let session = revision.chapter_engine_session(); + let line_of = |point: &RuntimeSourcePoint| { + session.target_block_offset( + page, + &PageArtifactSourcePoint { + node_path: point.node_path.clone(), + text_offset: point.text_offset, + }, + ratio, + ) + }; + match matched_by { + RuntimeSourceLocatorMatchedBy::SourceRange => { + line_of(&locator.source_range.as_ref()?.start) + } + RuntimeSourceLocatorMatchedBy::SourcePoint => line_of(locator.source_point.as_ref()?), + RuntimeSourceLocatorMatchedBy::Anchor => { + session.anchor_block_offset(page, locator.anchor_id.as_deref()?, ratio) + } + RuntimeSourceLocatorMatchedBy::Progression | RuntimeSourceLocatorMatchedBy::Href => None, + } +} + /// A digest of the text a frame's commands draw, in paint order. fn painted_digest<'a>(commands: impl Iterator) -> u64 { use std::hash::{Hash, Hasher}; @@ -312,21 +366,34 @@ pub(super) fn published_spread_target( }) } -/// Origin of a spread slot's content box in display-list space. +/// Where page `page_index` of a revision, shown in spread slot `slot` of an +/// artifact drawn at `ratio`, puts the content box its records are laid out +/// in, in the artifact's display-list space. /// /// `build_display_list_commands` lays the left page at x 0 and the right /// page at `page_width + spread_gap` (slot order matches -/// `frame.page_indexes`), then paints each page's blocks offset by the -/// page margins. Anything the artifact publishes as a rect must carry -/// the same origin or it cannot be compared with the pixels the host -/// drew. -pub(super) fn page_origin(config: &LayoutConfig, slot: usize) -> (f64, f64) { - let spread_offset_x = if slot == 0 { +/// `frame.page_indexes`), then paints each page's content at its margins, +/// a vertical-rl page's columns in from its right one. Anything the +/// artifact publishes as a rect must go through the same frame or it +/// cannot be compared with the pixels the host drew. +pub(super) fn page_frame( + revision: &RuntimeRevision, + page_index: usize, + slot: usize, + ratio: f64, +) -> Option { + let config = &revision.layout_config; + let slot_x = if slot == 0 { 0.0 } else { config.page_width + config.spread_gap }; - (spread_offset_x + config.margin_left, config.margin_top) + Some( + revision + .chapter_engine_session() + .record_frame(page_index, ratio)? + .moved_right(slot_x), + ) } /// What page `page_index` of a revision holds for a host, its rects lifted @@ -338,6 +405,7 @@ pub(super) fn reader_page_records( artifact_id: u64, page_index: usize, slot: usize, + ratio: f64, ) -> Result { let page = revision .chapter_engine_session() @@ -349,7 +417,12 @@ pub(super) fn reader_page_records( ) })?; let page = page.as_ref(); - let origin = page_origin(&revision.layout_config, slot); + let frame = page_frame(revision, page_index, slot, ratio).ok_or_else(|| { + ReaderError::new( + ReaderErrorKind::TargetNotPublished, + format!("artifact references unknown page {page_index}"), + ) + })?; // Each hit is the target the runtime's own page targets hold: one // classifier decides what it is, where its link leads and under which // key its footnote is held, for every host. @@ -362,12 +435,12 @@ pub(super) fn reader_page_records( hits: targets .into_iter() .zip(classes) - .map(|(target, class)| reader_hit(page_index, target, class, origin)) + .map(|(target, class)| reader_hit(page_index, target, class, frame)) .collect::, ReaderError>>()?, semantics: page .semantic_nodes() .into_iter() - .map(|node| reader_semantic_node(node, origin)) + .map(|node| reader_semantic_node(node, frame)) .collect(), text: text.text, text_length: u64_from_usize(text.text_length, "page text length")?, @@ -391,7 +464,7 @@ fn reader_hit( page_index: usize, target: PageArtifactTarget, class: PageTargetClass, - origin: (f64, f64), + frame: PageRecordFrame, ) -> Result { let source_point = class .source_locator @@ -400,7 +473,7 @@ fn reader_hit( .transpose()?; Ok(ReaderHitEntry { page_index: u32_from_usize(page_index, "hit page index")?, - bounds: reader_rect_at(target.bounds, origin), + bounds: reader_rect(frame.rect(target.bounds)), kind: match class.kind { RuntimePageTargetKind::Text => ReaderHitKind::Text, RuntimePageTargetKind::Link => ReaderHitKind::Link, @@ -429,10 +502,21 @@ fn reader_font_ref( weight: font.weight, shape_fingerprint: font.shape_fingerprint.clone(), byte_length: u64_from_usize(font.byte_length, "font byte length")?, + features: font + .features + .iter() + .map(|(tag, value)| ReaderFontFeature { + tag: tag.clone(), + value: *value, + }) + .collect(), }) } -fn reader_semantic_node(value: PageArtifactSemanticNode, origin: (f64, f64)) -> ReaderSemanticNode { +fn reader_semantic_node( + value: PageArtifactSemanticNode, + frame: PageRecordFrame, +) -> ReaderSemanticNode { ReaderSemanticNode { role: match value.role { PageArtifactSemanticRole::Heading => ReaderSemanticRole::Heading, @@ -449,21 +533,20 @@ fn reader_semantic_node(value: PageArtifactSemanticNode, origin: (f64, f64)) -> text: value.text, alt: value.alt, href: value.href, - bounds: reader_rect_at(value.bounds, origin), + bounds: reader_rect(frame.rect(value.bounds)), children: value .children .into_iter() - .map(|child| reader_semantic_node(child, origin)) + .map(|child| reader_semantic_node(child, frame)) .collect(), } } -/// Lifts an engine rect (content-box relative, page-local) into the -/// artifact's display-list space. -fn reader_rect_at(value: PageArtifactRect, origin: (f64, f64)) -> ReaderRect { +/// An engine rect already placed in the artifact's display-list space. +pub(super) fn reader_rect(value: PageArtifactRect) -> ReaderRect { ReaderRect { - x: value.x + origin.0, - y: value.y + origin.1, + x: value.x, + y: value.y, width: value.width, height: value.height, } diff --git a/crates/rito-core/src/runtime/reader_session/convert.rs b/crates/rito-core/src/runtime/reader_session/convert.rs index 300a55702..0909cec45 100644 --- a/crates/rito-core/src/runtime/reader_session/convert.rs +++ b/crates/rito-core/src/runtime/reader_session/convert.rs @@ -16,6 +16,7 @@ pub(super) fn layout_config(value: ReaderLayout) -> Result SpreadMode::Single, ReaderSpreadMode::Double => SpreadMode::Double, + ReaderSpreadMode::Scrolled => SpreadMode::Scrolled, }; Ok(create_layout_config(LayoutConfigInput { width: value.viewport_width, diff --git a/crates/rito-core/src/runtime/reader_session/interaction_tests.rs b/crates/rito-core/src/runtime/reader_session/interaction_tests.rs index 2ccd961cc..d069e2ebd 100644 --- a/crates/rito-core/src/runtime/reader_session/interaction_tests.rs +++ b/crates/rito-core/src/runtime/reader_session/interaction_tests.rs @@ -537,8 +537,10 @@ pub(super) fn selection_response_fixture() -> ReaderTextInteractionResponse { geometry: Some(ReaderCaretGeometry { x: 12.5, y: 40.0, - height: 18.0, + width: 18.0, + height: 0.0, }), + writing_mode: ReaderWritingMode::VerticalRl, href: "OEBPS/chapter-2.xhtml".to_owned(), source_point: point(12), }), @@ -642,22 +644,22 @@ pub(super) const POINTS_HEX: &str = concat!( /// Produced by the encoder; `rito_flutter`'s interaction wire test decodes the same bytes. pub(super) const SELECTION_HEX: &str = concat!( - "5249544f5449523101000000ee01000000000000090000000000000001016f00", + "5249544f5449523101000000f701000000000000090000000000000001017800", "0000000000001500000000000000030000000200000001000000000000000400", - "0000010100000000000029400000000000004440000000000000324015000000", - "4f454250532f636861707465722d322e7868746d6c1800000000000000030000", - "000100000000000000040000000c00000000000000003e010000000000001500", - "0000000000000300000002000000010000000000000004000000011500000000", - "0000000300000002000000010000000000000014000000001500000000000000", - "0300000002000000010000000000000004000000011500000000000000030000", - "000200000001000000000000001400000000100000007468652071756f746564", - "20776f726473150000004f454250532f636861707465722d322e7868746d6c18", - "00000000000000030000000100000000000000040000000c0000000000000015", - "0000004f454250532f636861707465722d322e7868746d6c1800000000000000", - "030000000100000000000000040000001c000000000000000100000038000000", - "0000000003000000000000000000294000000000000044400000000000105640", - "0000000000003240020000000100000000000000040000001400000001000000", - "0000205e40010000000000c47240", + "0000010100000000000029400000000000004440000000000000324000000000", + "0000000001150000004f454250532f636861707465722d322e7868746d6c1800", + "000000000000030000000100000000000000040000000c00000000000000003e", + "0100000000000015000000000000000300000002000000010000000000000004", + "0000000115000000000000000300000002000000010000000000000014000000", + "0015000000000000000300000002000000010000000000000004000000011500", + "0000000000000300000002000000010000000000000014000000001000000074", + "68652071756f74656420776f726473150000004f454250532f63686170746572", + "2d322e7868746d6c180000000000000003000000010000000000000004000000", + "0c00000000000000150000004f454250532f636861707465722d322e7868746d", + "6c1800000000000000030000000100000000000000040000001c000000000000", + "0001000000380000000000000003000000000000000000294000000000000044", + "4000000000001056400000000000003240020000000100000000000000040000", + "0014000000010000000000205e40010000000000c47240", ); /// Produced by the encoder; `rito_flutter`'s interaction wire test decodes the same bytes. @@ -1053,6 +1055,7 @@ pub(super) fn location_result_fixture() -> ReaderNavigationResult { page_index: 5, drawn: true, matched_by: ReaderLocatorMatch::Anchor, + block_offset: Some(212.25), }) } @@ -1073,7 +1076,8 @@ pub(super) const COMPARE_HEX: &str = concat!( ); /// Produced by the encoder; `rito_flutter`'s navigation wire test decodes the same bytes. -pub(super) const LOCATION_HEX: &str = "5249544f4e565231010000001c000000000000000100050000000102"; +pub(super) const LOCATION_HEX: &str = + "5249544f4e5652310100000025000000000000000100050000000102010000000000886a40"; /// Produced by the encoder; `rito_flutter`'s navigation wire test decodes the same bytes. pub(super) const TOC_HEX: &str = "5249544f4e565231010000001a00000000000000000103000000"; diff --git a/crates/rito-core/src/runtime/reader_session/interaction_types.rs b/crates/rito-core/src/runtime/reader_session/interaction_types.rs index 08865ad53..c03e18f24 100644 --- a/crates/rito-core/src/runtime/reader_session/interaction_types.rs +++ b/crates/rito-core/src/runtime/reader_session/interaction_types.rs @@ -32,19 +32,31 @@ pub struct ReaderCaretAddress { pub affinity: ReaderCaretAffinity, } -/// A caret's line in display-list space: its top and its height. +/// A caret in display-list space: the segment across its line, as a rect +/// with no extent along the line — `width` 0 on a horizontal line, +/// `height` 0 down a vertical one. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ReaderCaretGeometry { pub x: f64, pub y: f64, + pub width: f64, pub height: f64, } +/// The writing mode of the line a caret is on: which way its arrow keys +/// move it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReaderWritingMode { + HorizontalTb, + VerticalRl, +} + #[derive(Debug, Clone, PartialEq)] pub struct ReaderCaret { pub address: ReaderCaretAddress, /// Present when the caret's page is one this artifact draws. pub geometry: Option, + pub writing_mode: ReaderWritingMode, /// Canonical manifest href and source point the caret sits at. pub href: String, pub source_point: ReaderSourcePoint, @@ -290,7 +302,7 @@ pub struct ReaderNavigationRequest { pub query: ReaderNavigationQuery, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum ReaderLocation { /// The locator lands on `page_index` of the revision; `drawn` says /// whether the asking artifact draws that page. @@ -298,6 +310,10 @@ pub enum ReaderLocation { page_index: u32, drawn: bool, matched_by: super::ReaderLocatorMatch, + /// In a scrolled revision, where the line the locator lands on + /// starts in that page's window, as an artifact's target block + /// offset; absent for a page, and for a locator that names no line. + block_offset: Option, }, /// The locator's chapter is not laid out in this revision. NotLaidOut, @@ -305,7 +321,7 @@ pub enum ReaderLocation { Unavailable, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum ReaderNavigationResult { /// The entry's preorder index — the `toc_id` the publication carries. TocEntry(Option), @@ -318,7 +334,7 @@ pub enum ReaderNavigationResult { /// chapter (its pages count from zero); a whole-book revision lays out /// every chapter and its pages are book pages. A host addresses a spread /// by its position in [`Self::spreads`]. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct ReaderRevisionLayout { pub page_count: u32, /// Every spread's pages, in reading order. @@ -335,6 +351,19 @@ pub struct ReaderRevisionLayout { /// chapters number and spread them; a TOC entry whose target lies past /// them is not placed yet. pub complete: bool, + /// The direction a scrolled revision's chapters run in; absent for a + /// revision of pages. + pub scroll_axis: Option, +} + +/// The direction a scrolled revision's chapters run: each chapter follows +/// the one before it along this axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReaderScrollAxis { + /// Down the screen, as horizontal text's blocks stack. + Down, + /// Leftwards, as vertical right-to-left text's columns follow. + Left, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -344,12 +373,15 @@ pub struct ReaderSpreadPages { } /// The pages one spine item is laid out on, both ends included. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct ReaderChapterPages { /// The item's index in the publication's spine. pub spine_index: u32, pub start_page: u32, pub end_page: u32, + /// A scrolled chapter's extent along the scroll axis, in CSS pixels: + /// its margins and its strip. Absent in a revision of pages. + pub extent: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/rito-core/src/runtime/reader_session/layout_tests.rs b/crates/rito-core/src/runtime/reader_session/layout_tests.rs index db05987a1..e24b9ff9a 100644 --- a/crates/rito-core/src/runtime/reader_session/layout_tests.rs +++ b/crates/rito-core/src/runtime/reader_session/layout_tests.rs @@ -133,6 +133,7 @@ fn a_chapter_layout_is_that_chapters_slice_of_the_whole_book_layout() { spine_index: as_u32(spine_index), start_page: 0, end_page: as_u32(end - start), + extent: None, }] ); assert_eq!( @@ -212,6 +213,7 @@ fn a_whole_book_layout_matches_the_whole_book_revision() { spine_index: as_u32(spine_index), start_page: as_u32(chapter.start_page), end_page: as_u32(chapter.end_page), + extent: None, }) .collect::>() ); @@ -423,11 +425,13 @@ fn layout_result_fixture() -> ReaderNavigationResult { spine_index: 1, start_page: 0, end_page: 2, + extent: Some(4_200.5), }, ReaderChapterPages { spine_index: 3, start_page: 3, end_page: 4, + extent: Some(1_900.25), }, ], toc_targets: vec![ @@ -442,6 +446,7 @@ fn layout_result_fixture() -> ReaderNavigationResult { ], active_toc_entry_by_page: vec![None, Some(2), Some(2), Some(6), Some(6)], complete: false, + scroll_axis: Some(ReaderScrollAxis::Left), }) } @@ -475,10 +480,11 @@ const LAYOUT_REQUEST_HEX: &str = concat!( /// Produced by the encoder; the `rito_flutter` and `@ritojs/core-wasm` /// layout wire tests pin the same bytes. const LAYOUT_RESULT_HEX: &str = concat!( - "5249544f4e565231010000007e00000000000000030500000003000000000000", + "5249544f4e565231010000009200000000000000030500000003000000000000", "0000010000000102000000030000000104000000020000000100000000000000", - "0200000003000000030000000400000002000000020000000000000006000000", - "030000000500000000010200000001020000000106000000010600000000", + "0200000001000000008068b040030000000300000004000000010000000000b1", + "9d40020000000200000000000000060000000300000005000000000102000000", + "010200000001060000000106000000000101", ); /// Produced by the encoder; the `rito_flutter` and `@ritojs/core-wasm` diff --git a/crates/rito-core/src/runtime/reader_session/publication_info/tests.rs b/crates/rito-core/src/runtime/reader_session/publication_info/tests.rs index 9a2102cfb..c3be13dcd 100644 --- a/crates/rito-core/src/runtime/reader_session/publication_info/tests.rs +++ b/crates/rito-core/src/runtime/reader_session/publication_info/tests.rs @@ -136,6 +136,7 @@ fn fixture_document() -> LoadedEpubDocument { children: Vec::new(), }, ], + page_progression: Default::default(), }, stylesheets: Vec::new(), fonts: Vec::new(), diff --git a/crates/rito-core/src/runtime/reader_session/scrolled_tests.rs b/crates/rito-core/src/runtime/reader_session/scrolled_tests.rs new file mode 100644 index 000000000..99513491b --- /dev/null +++ b/crates/rito-core/src/runtime/reader_session/scrolled_tests.rs @@ -0,0 +1,241 @@ +//! A scrolled reader session: each chapter's windows are its spreads, an +//! artifact says where its locator's line starts in its window, and the +//! revision's layout says how long each chapter runs and which way. + +use crate::runtime::tests::fixture::{long_paragraphs_fixture_epub, vertical_book_fixture_epub}; + +use super::{ + tests::{adopt_initial, open_test_session, request}, + *, +}; + +const SESSION: u64 = 512; + +fn scrolled(request_id: u64, locator: ReaderLocator) -> ReaderArtifactRequest { + let mut request = request(SESSION, request_id, &locator.href); + request.layout.spread_mode = ReaderSpreadMode::Scrolled; + request.locator = locator; + request +} + +fn layout_of(session: &mut ReaderSession, artifact_id: u64) -> ReaderRevisionLayout { + match session + .resolve_navigation(ReaderNavigationRequest { + session_id: SESSION, + query: ReaderNavigationQuery::Layout { + artifact_id, + whole: true, + }, + }) + .expect("layout resolves") + { + ReaderNavigationResult::Layout(layout) => layout, + other => panic!("a layout answer: {other:?}"), + } +} + +#[test] +fn a_scrolled_session_serves_windows_and_says_how_long_its_chapters_run() { + let mut session = + open_test_session(SESSION, long_paragraphs_fixture_epub()).expect("reader session opens"); + let href = session.publication().spine[0].href.clone(); + let first = session + .request_artifact(scrolled( + 1, + ReaderLocator { + href: href.clone(), + anchor_id: None, + source_point: None, + source_range: None, + progression: Some(0.5), + }, + )) + .expect("scrolled artifact"); + adopt_initial(&mut session, SESSION, first.artifact_id); + + // A progression is a position among the chapter's windows: the window + // half way through them, shown from its top. + let layout = layout_of(&mut session, first.artifact_id); + assert_eq!(layout.scroll_axis, Some(ReaderScrollAxis::Down)); + let chapter = layout.chapters[0]; + let extent = chapter + .extent + .expect("a scrolled chapter says how long it runs"); + let window_length = 640.0; + let span = chapter.end_page - chapter.start_page; + assert_eq!( + first.local_page_index, + (f64::from(span) * 0.5).round() as u32 + ); + assert_eq!(first.target_block_offset, None); + assert_eq!(first.pages[0].height, window_length); + + // The chapter's last window ends where the chapter does. + let last = layout.spreads.len() - 1; + let last_artifact = session + .peek_spread(ReaderSpreadRequest { + session_id: SESSION, + request_id: 2, + from_artifact_id: first.artifact_id, + spread_index: u32::try_from(last).expect("spread index"), + }) + .expect("last window publishes"); + assert_eq!( + last_artifact.pages[0].height, + extent - last as f64 * window_length + ); + // A spread's locator is its reading point, whose line it is shown from. + let height = last_artifact.pages[0].height; + assert!(last_artifact + .target_block_offset + .is_some_and(|offset| (0.0..height).contains(&offset))); +} + +#[test] +fn a_scrolled_session_holds_more_live_windows_than_a_paged_one() { + use super::session::READER_SCROLLED_LIVE_ARTIFACT_CAP; + + let mut session = + open_test_session(SESSION, long_paragraphs_fixture_epub()).expect("reader session opens"); + let href = session.publication().spine[0].href.clone(); + let at = |request_id: u64| { + scrolled( + request_id, + ReaderLocator { + href: href.clone(), + anchor_id: None, + source_point: None, + source_range: None, + progression: None, + }, + ) + }; + for request_id in 1..=u64::from(READER_SCROLLED_LIVE_ARTIFACT_CAP) { + session + .request_artifact(at(request_id)) + .expect("a window fits the scrolled cap"); + } + let capped = session + .request_artifact(at(u64::from(READER_SCROLLED_LIVE_ARTIFACT_CAP) + 1)) + .expect_err("the scrolled cap holds"); + assert_eq!(capped.kind, ReaderErrorKind::InvalidRequest); +} + +#[test] +fn a_right_to_left_book_scrolls_leftwards() { + let mut session = + open_test_session(SESSION, vertical_book_fixture_epub()).expect("reader session opens"); + let at = |request_id: u64, href: String| { + scrolled( + request_id, + ReaderLocator { + href, + anchor_id: None, + source_point: None, + source_range: None, + progression: None, + }, + ) + }; + let spine: Vec = session + .publication() + .spine + .iter() + .map(|item| item.href.clone()) + .collect(); + let plate = session + .request_artifact(at(1, spine[0].clone())) + .expect("plate artifact"); + adopt_initial(&mut session, SESSION, plate.artifact_id); + let layout = layout_of(&mut session, plate.artifact_id); + assert_eq!(layout.scroll_axis, Some(ReaderScrollAxis::Left)); + // The horizontal plate is paginated across the axis, a page wide a + // page, and shown whole, from no line in particular. + let page_width = 420.0; + let pages = layout.chapters[0]; + assert_eq!( + pages.extent, + Some(f64::from(pages.end_page - pages.start_page + 1) * page_width) + ); + assert_eq!(plate.target_block_offset, None); + // The vertical chapter runs on leftwards as one strip. + let columns = session + .request_artifact(at(2, spine[1].clone())) + .expect("columns artifact"); + let layout = layout_of(&mut session, columns.artifact_id); + assert_eq!(layout.scroll_axis, Some(ReaderScrollAxis::Left)); + let extent = layout.chapters[0] + .extent + .expect("a strip says how long it runs"); + assert!(extent > 2.0 * page_width); + assert_eq!( + (columns.pages[0].width, columns.pages[0].height), + (page_width, 640.0) + ); + // Its last window ends at the chapter's left end. + let last = layout.spreads.len() - 1; + let last_window = session + .peek_spread(ReaderSpreadRequest { + session_id: SESSION, + request_id: 3, + from_artifact_id: columns.artifact_id, + spread_index: u32::try_from(last).expect("spread index"), + }) + .expect("last window publishes"); + assert_eq!( + (last_window.pages[0].width, last_window.pages[0].height), + (extent - last as f64 * page_width, 640.0) + ); +} + +#[test] +fn a_located_point_says_where_its_line_starts_in_its_window() { + let mut session = + open_test_session(SESSION, long_paragraphs_fixture_epub()).expect("reader session opens"); + let href = session.publication().spine[0].href.clone(); + let first = session + .request_artifact(scrolled( + 1, + ReaderLocator { + href, + anchor_id: None, + source_point: None, + source_range: None, + progression: Some(0.5), + }, + )) + .expect("scrolled artifact"); + adopt_initial(&mut session, SESSION, first.artifact_id); + // A window reached by position is shown from its reading point, a + // source point. + let shown = session + .peek_spread(ReaderSpreadRequest { + session_id: SESSION, + request_id: 2, + from_artifact_id: first.artifact_id, + spread_index: 3, + }) + .expect("a window publishes"); + let point = shown.locator.clone(); + assert!(point.source_point.is_some()); + let located = session + .resolve_navigation(ReaderNavigationRequest { + session_id: SESSION, + query: ReaderNavigationQuery::Locate { + artifact_id: first.artifact_id, + locator: point, + }, + }) + .expect("the point locates"); + let ReaderNavigationResult::Location(ReaderLocation::Page { + page_index, + block_offset, + .. + }) = located + else { + panic!("the point lands on a page: {located:?}"); + }; + assert_eq!(page_index, shown.local_page_index); + assert!(block_offset.is_some()); + assert_eq!(block_offset, shown.target_block_offset); +} diff --git a/crates/rito-core/src/runtime/reader_session/session.rs b/crates/rito-core/src/runtime/reader_session/session.rs index fd6a0631c..3432c14ba 100644 --- a/crates/rito-core/src/runtime/reader_session/session.rs +++ b/crates/rito-core/src/runtime/reader_session/session.rs @@ -27,7 +27,7 @@ use super::{ ReaderExactSourceRangeRequest, ReaderExactSourceRangeResolution, ReaderExactSourceRangeStatus, ReaderExactSourceRect, ReaderFootnote, ReaderFootnoteKind, ReaderForegroundHandoff, ReaderForegroundHandoffAck, ReaderLocator, ReaderNavigation, ReaderPageRecords, - ReaderPublication, ReaderRect, ReaderResource, ReaderResourceKind, ReaderSearchRequest, + ReaderPublication, ReaderResource, ReaderResourceKind, ReaderSearchRequest, ReaderSearchResponse, ReaderSearchResult, ReaderSpreadRequest, ReaderTextPosition, ReaderTextRangeGeometry, ReaderTextRangeRequest, ReaderTextRect, ReaderTextRenderingProfile, READER_EXTERNAL_ID_MAX, @@ -58,6 +58,11 @@ use project::owner_from_created; // candidate, with one slot of slack. pub const READER_LIVE_ARTIFACT_CAP: u32 = 6; +/// A scrolled reader's cap: the windows in view (a short chapter's can be +/// several), the ones scrolling in on either side and an in-flight +/// foreground candidate. +pub const READER_SCROLLED_LIVE_ARTIFACT_CAP: u32 = 10; + /// The most pages a host asks one background step to lay out when the /// engine shares a thread with the frames it draws: a step is what stands /// between two frames, so it is a few pages, not the book. Both hosts ask diff --git a/crates/rito-core/src/runtime/reader_session/session/content.rs b/crates/rito-core/src/runtime/reader_session/session/content.rs index 9de586a27..dd95734c9 100644 --- a/crates/rito-core/src/runtime/reader_session/session/content.rs +++ b/crates/rito-core/src/runtime/reader_session/session/content.rs @@ -6,12 +6,13 @@ //! geometry values into their reader session shapes. use crate::runtime::{ + page_artifact::{PageArtifactRect, PageRecordFrame}, RuntimeExactSourceRangeRequest, RuntimeExactSourceRangeResolution, RuntimeRevision, RuntimeSearchRequest, RuntimeSourceLocator, RuntimeTextRangeGeometryRequest, }; use super::super::{ - artifact::{page_origin, reader_page_records}, + artifact::{page_frame, reader_page_records, reader_rect}, convert::{reader_locator, runtime_source_range}, }; use super::{ @@ -23,7 +24,7 @@ use super::{ reader_resource_bytes_max, runtime_resource_kind, u32_from_usize, usize_from_u32, ReaderError, ReaderErrorKind, ReaderExactSourceRangeRequest, ReaderExactSourceRangeResolution, ReaderExactSourceRangeStatus, ReaderFootnote, ReaderFootnoteKind, ReaderPageRecords, - ReaderRect, ReaderResource, ReaderResourceKind, ReaderRevisionBacking, ReaderSearchRequest, + ReaderResource, ReaderResourceKind, ReaderRevisionBacking, ReaderSearchRequest, ReaderSearchResponse, ReaderSearchResult, ReaderSession, ReaderTextPosition, ReaderTextRangeGeometry, ReaderTextRangeRequest, ReaderTextRect, }; @@ -198,6 +199,7 @@ impl ReaderSession { artifact_id, usize_from_u32(page_index, "page index")?, slot, + self.render_ratio, ) } @@ -401,7 +403,7 @@ impl ReaderSession { start: runtime_text_position(request.start)?, end: runtime_text_position(request.end)?, }; - let (geometry, origin) = match artifact.backing { + let (geometry, frame) = match artifact.backing { ReaderRevisionBacking::ChapterLocal => { let owner = self .revisions @@ -412,15 +414,19 @@ impl ReaderSession { .document .require_chapter_local_owner(&owner) .map_err(engine_error)?; - let origin = - page_display_origin(revision, artifact.local_spread_index, page_index)?; + let frame = page_display_frame( + revision, + artifact.local_spread_index, + page_index, + self.render_ratio, + )?; let geometry = crate::runtime::page::text_range_geometry( &owner.revision_id, revision, runtime_request, ) .map_err(engine_error)?; - (geometry, origin) + (geometry, frame) } ReaderRevisionBacking::Publication => { let owner = self @@ -433,15 +439,19 @@ impl ReaderSession { .revisions .get(&owner.revision_id) .ok_or_else(|| missing_artifact_revision(artifact.backing))?; - let origin = - page_display_origin(revision, artifact.local_spread_index, page_index)?; + let frame = page_display_frame( + revision, + artifact.local_spread_index, + page_index, + self.render_ratio, + )?; let geometry = crate::runtime::page::text_range_geometry( &owner.revision_id, revision, runtime_request, ) .map_err(engine_error)?; - (geometry, origin) + (geometry, frame) } }; Ok(ReaderTextRangeGeometry { @@ -452,12 +462,12 @@ impl ReaderSession { .into_iter() .map(|rect| { Ok(ReaderTextRect { - bounds: ReaderRect { - x: rect.x + origin.0, - y: rect.y + origin.1, + bounds: reader_rect(frame.rect(PageArtifactRect { + x: rect.x, + y: rect.y, width: rect.width, height: rect.height, - }, + })), block_index: u32_from_usize(rect.block_index, "text rect block index")?, line_index: u32_from_usize(rect.line_index, "text rect line index")?, run_index: u32_from_usize(rect.run_index, "text rect run index")?, @@ -522,13 +532,15 @@ const fn reader_footnote_kind(kind: crate::interaction::FootnoteKind) -> ReaderF } } -/// Display-list origin of the page slot holding `page_index` inside the -/// artifact's spread, so geometry lands where the pen painted. -pub(super) fn page_display_origin( +/// Display-list frame of the page slot holding `page_index` inside the +/// artifact's spread drawn at `ratio`, so geometry lands where the pen +/// painted. +fn page_display_frame( revision: &RuntimeRevision, spread_index: usize, page_index: usize, -) -> Result<(f64, f64), ReaderError> { + ratio: f64, +) -> Result { let page_indexes = revision .chapter_engine_session() .spread_pages(spread_index) @@ -542,7 +554,8 @@ pub(super) fn page_display_origin( format!("page {page_index} is not part of this artifact"), ) })?; - Ok(page_origin(&revision.layout_config, slot)) + page_frame(revision, page_index, slot, ratio) + .ok_or_else(|| target_not_published("artifact page is not published")) } fn runtime_text_position( diff --git a/crates/rito-core/src/runtime/reader_session/session/errors.rs b/crates/rito-core/src/runtime/reader_session/session/errors.rs index c72073dc5..613aa8bf6 100644 --- a/crates/rito-core/src/runtime/reader_session/session/errors.rs +++ b/crates/rito-core/src/runtime/reader_session/session/errors.rs @@ -6,8 +6,9 @@ use super::{ ReaderError, ReaderErrorKind, ReaderRevisionBacking, ReaderSession, READER_EXTERNAL_ID_MAX, - READER_LIVE_ARTIFACT_CAP, + READER_LIVE_ARTIFACT_CAP, READER_SCROLLED_LIVE_ARTIFACT_CAP, }; +use crate::layout::SpreadMode; impl ReaderSession { pub(super) fn validate_request_identity( @@ -35,17 +36,43 @@ impl ReaderSession { Ok(()) } - pub(super) fn require_artifact_capacity(&self) -> Result<(), ReaderError> { - if self.live_artifact_count() < READER_LIVE_ARTIFACT_CAP { + /// Room for one more live artifact of a revision laid out in + /// `spread_mode`. + pub(super) fn require_artifact_capacity( + &self, + spread_mode: SpreadMode, + ) -> Result<(), ReaderError> { + let cap = match spread_mode { + SpreadMode::Scrolled => READER_SCROLLED_LIVE_ARTIFACT_CAP, + SpreadMode::Single | SpreadMode::Double => READER_LIVE_ARTIFACT_CAP, + }; + if self.live_artifact_count() < cap { return Ok(()); } Err(ReaderError::new( ReaderErrorKind::InvalidRequest, - format!( - "live artifact cap {READER_LIVE_ARTIFACT_CAP} reached; release an old artifact" - ), + format!("live artifact cap {cap} reached; release an old artifact"), )) } + + /// How the revision behind a live artifact lays its spreads out; a + /// single-page spread for an artifact this session does not hold, whose + /// request fails on it after the capacity check. + pub(super) fn artifact_spread_mode(&self, artifact_id: u64) -> SpreadMode { + self.artifacts + .get(&artifact_id) + .and_then(|artifact| match artifact.backing { + ReaderRevisionBacking::ChapterLocal => self + .revisions + .get(&artifact.revision_id) + .map(|revision| revision.layout.spread_mode), + ReaderRevisionBacking::Publication => self + .publication_revisions + .get(&artifact.revision_id) + .map(|revision| revision.layout.spread_mode), + }) + .unwrap_or(SpreadMode::Single) + } } pub(super) fn take_identity(next: &mut u64, field: &str) -> Result { diff --git a/crates/rito-core/src/runtime/reader_session/session/interaction.rs b/crates/rito-core/src/runtime/reader_session/session/interaction.rs index 015a6c07a..1141e4f01 100644 --- a/crates/rito-core/src/runtime/reader_session/session/interaction.rs +++ b/crates/rito-core/src/runtime/reader_session/session/interaction.rs @@ -4,8 +4,8 @@ //! through the artifact's revision exactly as a browser revision does, and //! an annotation target is built and located by the same source cascade. //! The only work here is moving points and geometry between an artifact's -//! display-list space and its pages, and keeping rectangles to the pages -//! the artifact draws. +//! display-list space and its pages' records, through each page's frame, +//! and keeping rectangles to the pages the artifact draws. use crate::{ interaction::{ @@ -13,7 +13,9 @@ use crate::{ TextSelectionBoundary, TextSelectionMovement, }, runtime::{ - annotation_target_from_json, annotation_target_to_json, navigation::spread_index_for_page, + annotation_target_from_json, annotation_target_to_json, + navigation::spread_index_for_page, + page_artifact::{PageArtifactRect, PageRecordFrame, PageWritingMode}, AnnotationOrphanReason, AnnotationTarget, AnnotationTargetResolution, RuntimeExactTextRangeRect, RuntimeRevision, RuntimeSourceLocatorErrorKind, RuntimeTextCaret, RuntimeTextCaretResolution, RuntimeTextPointRequest, RuntimeTextRange, @@ -25,7 +27,7 @@ use crate::{ }; use super::super::{ - artifact::page_origin, + artifact::{page_frame, reader_rect}, convert::{reader_source_point, runtime_source_range}, ReaderAnnotationLevel, ReaderAnnotationQuery, ReaderAnnotationRequest, ReaderAnnotationResponse, ReaderAnnotationTarget, ReaderCaret, ReaderCaretAddress, @@ -33,13 +35,14 @@ use super::super::{ ReaderSelectionMovement, ReaderSelectionResult, ReaderSourceRange, ReaderTextInteractionQuery, ReaderTextInteractionRequest, ReaderTextInteractionResponse, ReaderTextInteractionResult, ReaderTextInteractionUnavailableReason, ReaderTextPoint, ReaderTextSelection, + ReaderWritingMode, }; use super::{ errors::{ engine_error, missing_artifact_revision, unknown_artifact, validate_external_request_id, }, u32_from_usize, usize_from_u32, ReaderError, ReaderErrorKind, ReaderExactSourceRect, - ReaderRect, ReaderRevisionBacking, ReaderSession, ReaderTextPosition, + ReaderRevisionBacking, ReaderSession, ReaderTextPosition, }; /// The revision behind an artifact and the spread of it the artifact draws. @@ -48,6 +51,9 @@ pub(super) struct ArtifactView<'a> { pub(super) revision: &'a RuntimeRevision, spread_index: usize, every_page: bool, + /// The device ratio the artifact is drawn at, which places a scrolled + /// window's records on its pixels. + ratio: f64, } impl ArtifactView<'_> { @@ -60,23 +66,20 @@ impl ArtifactView<'_> { fn page_point(&self, point: ReaderTextPoint) -> Result { let page_index = usize_from_u32(point.page_index, "text point page index")?; - let origin = self.origin_of(page_index).ok_or_else(|| { + let frame = self.frame_of(page_index).ok_or_else(|| { ReaderError::new( ReaderErrorKind::InvalidRequest, format!("page {page_index} is not part of this artifact"), ) })?; - Ok(RuntimeTextPointRequest { - page_index, - x: point.x - origin.0, - y: point.y - origin.1, - }) + let (x, y) = frame.point(point.x, point.y); + Ok(RuntimeTextPointRequest { page_index, x, y }) } - /// The display-list origin of a page this view answers for: a page the + /// The display-list frame of a page this view answers for: a page the /// artifact draws, or any page of the revision when the view covers /// every page — its slot in the spread that draws it. - pub(super) fn origin_of(&self, page_index: usize) -> Option<(f64, f64)> { + pub(super) fn frame_of(&self, page_index: usize) -> Option { let spread_index = if self.every_page { spread_index_for_page(self.revision, page_index) } else { @@ -87,7 +90,7 @@ impl ArtifactView<'_> { .chapter_engine_session() .spread_pages(spread_index)?; let slot = pages.iter().position(|index| *index == page_index)?; - Some(page_origin(&self.revision.layout_config, slot)) + page_frame(self.revision, page_index, slot, self.ratio) } fn caret(&self, caret: RuntimeTextCaret) -> Result { @@ -95,39 +98,51 @@ impl ArtifactView<'_> { .source_locator .source_point .ok_or_else(|| engine_error("a resolved caret carries no source point"))?; + let page_index = caret.address.page_index; + let writing_mode = self + .revision + .chapter_engine_session() + .record_frame(page_index, self.ratio) + .map_or(PageWritingMode::HorizontalTb, PageRecordFrame::writing_mode); Ok(ReaderCaret { address: reader_address(caret.address)?, - geometry: self - .origin_of(caret.address.page_index) - .map(|origin| ReaderCaretGeometry { - x: caret.geometry.x + origin.0, - y: caret.geometry.y + origin.1, - height: caret.geometry.height, - }), + geometry: self.frame_of(page_index).map(|frame| { + let drawn = frame.caret(caret.geometry.x, caret.geometry.y, caret.geometry.height); + ReaderCaretGeometry { + x: drawn.x, + y: drawn.y, + width: drawn.width, + height: drawn.height, + } + }), + writing_mode: match writing_mode { + PageWritingMode::HorizontalTb => ReaderWritingMode::HorizontalTb, + PageWritingMode::VerticalRl => ReaderWritingMode::VerticalRl, + }, href: caret.source_locator.href, source_point: reader_source_point(source_point)?, }) } /// The rects this view answers for, in display-list space. A range's - /// rects come page by page, so the origin is looked up once per page. + /// rects come page by page, so the frame is looked up once per page. pub(super) fn rects( &self, rects: &[RuntimeExactTextRangeRect], ) -> Result, ReaderError> { let mut placed = Vec::new(); - let mut page: Option<(usize, Option<(f64, f64)>)> = None; + let mut page: Option<(usize, Option)> = None; for rect in rects { - let origin = match page { - Some((index, origin)) if index == rect.page_index => origin, + let frame = match page { + Some((index, frame)) if index == rect.page_index => frame, _ => { - let origin = self.origin_of(rect.page_index); - page = Some((rect.page_index, origin)); - origin + let frame = self.frame_of(rect.page_index); + page = Some((rect.page_index, frame)); + frame } }; - if let Some(origin) = origin { - placed.push(reader_rect(rect, origin)?); + if let Some(frame) = frame { + placed.push(reader_exact_rect(rect, frame)?); } } Ok(placed) @@ -269,6 +284,7 @@ impl ReaderSession { revision, spread_index: artifact.local_spread_index, every_page: false, + ratio: self.render_ratio, }) } @@ -470,18 +486,18 @@ fn annotation_error(error: crate::runtime::RuntimeSourceLocatorError) -> ReaderE } } -fn reader_rect( +fn reader_exact_rect( rect: &RuntimeExactTextRangeRect, - origin: (f64, f64), + frame: PageRecordFrame, ) -> Result { Ok(ReaderExactSourceRect { page_index: u32_from_usize(rect.page_index, "selection rect page index")?, - bounds: ReaderRect { - x: rect.x + origin.0, - y: rect.y + origin.1, + bounds: reader_rect(frame.rect(PageArtifactRect { + x: rect.x, + y: rect.y, width: rect.width, height: rect.height, - }, + })), block_index: u32_from_usize(rect.block_index, "selection rect block index")?, line_index: u32_from_usize(rect.line_index, "selection rect line index")?, run_index: u32_from_usize(rect.run_index, "selection rect run index")?, diff --git a/crates/rito-core/src/runtime/reader_session/session/position.rs b/crates/rito-core/src/runtime/reader_session/session/position.rs index f62143252..d1b9358c8 100644 --- a/crates/rito-core/src/runtime/reader_session/session/position.rs +++ b/crates/rito-core/src/runtime/reader_session/session/position.rs @@ -3,6 +3,8 @@ //! reading order of two positions. Each answer comes from the engine //! functions a browser revision uses, so no host derives them itself. +use crate::fragment_window::ScrollAxis; +use crate::layout::SpreadMode; use crate::runtime::{ source_locator::{active_toc_entries_by_page, active_toc_entry, TocTargetPosition}, RuntimeSourceLocatorError, RuntimeSourceLocatorErrorKind, RuntimeSourceLocatorResolution, @@ -10,10 +12,11 @@ use crate::runtime::{ }; use super::super::{ + artifact::scrolled_block_offset, convert::{locator_match, runtime_locator}, ReaderChapterPages, ReaderLocation, ReaderNavigationQuery, ReaderNavigationRequest, - ReaderNavigationResult, ReaderRevisionLayout, ReaderSourcePoint, ReaderSpreadPages, - ReaderTocTargetPage, + ReaderNavigationResult, ReaderRevisionLayout, ReaderScrollAxis, ReaderSourcePoint, + ReaderSpreadPages, ReaderTocTargetPage, }; use super::{ errors::{engine_error, validate_external_request_id}, @@ -116,6 +119,7 @@ impl ReaderSession { spine_index: u32_from_usize(spine_index, "spine index")?, start_page: u32_from_usize(range.start_page, "chapter start page")?, end_page: u32_from_usize(range.end_page, "chapter end page")?, + extent: engine.chapter_extent(&item.idref), }); } } @@ -143,6 +147,12 @@ impl ReaderSession { toc_targets, active_toc_entry_by_page, complete: self.artifact_revision_is_complete(artifact_id), + scroll_axis: (view.revision.layout_config.spread_mode == SpreadMode::Scrolled).then( + || match ScrollAxis::of_book(self.document.document().package.page_progression) { + ScrollAxis::Down => ReaderScrollAxis::Down, + ScrollAxis::Left => ReaderScrollAxis::Left, + }, + ), })) } @@ -210,8 +220,15 @@ impl ReaderSession { .. } => ReaderLocation::Page { page_index: u32_from_usize(page_index, "located page")?, - drawn: view.origin_of(page_index).is_some(), + drawn: view.frame_of(page_index).is_some(), matched_by: locator_match(matched_by), + block_offset: scrolled_block_offset( + view.revision, + page_index, + prepared.locator(), + matched_by, + self.render_ratio, + ), }, RuntimeSourceLocatorResolution::Pending { .. } => ReaderLocation::NotLaidOut, }; diff --git a/crates/rito-core/src/runtime/reader_session/session/probe.rs b/crates/rito-core/src/runtime/reader_session/session/probe.rs index d33e48823..db3ef220f 100644 --- a/crates/rito-core/src/runtime/reader_session/session/probe.rs +++ b/crates/rito-core/src/runtime/reader_session/session/probe.rs @@ -32,6 +32,24 @@ impl ReaderSession { )) } + /// What an artifact's spread paints before it is lowered, and how far + /// it is moved once lowered, in device pixels: the drawing its records + /// are held to. + #[cfg(test)] + pub(in crate::runtime::reader_session) fn artifact_paint( + &self, + artifact_id: u64, + ) -> Option<(Vec, (f64, f64))> { + let spread = self.artifacts.get(&artifact_id)?.local_spread_index; + let view = self.artifact_view(artifact_id).ok()?; + let frame = view + .revision + .chapter_engine_session() + .frame(spread, self.render_ratio) + .ok()??; + Some((frame.commands.to_vec(), frame.device_origin)) + } + #[cfg(test)] pub(in crate::runtime::reader_session) const fn exact_cache_hit_count(&self) -> u64 { self.exact_cache_hit_count diff --git a/crates/rito-core/src/runtime/reader_session/session/publication_pipeline.rs b/crates/rito-core/src/runtime/reader_session/session/publication_pipeline.rs index da8d50874..3b3ee7667 100644 --- a/crates/rito-core/src/runtime/reader_session/session/publication_pipeline.rs +++ b/crates/rito-core/src/runtime/reader_session/session/publication_pipeline.rs @@ -67,7 +67,7 @@ impl ReaderSession { )); } if needs_handoff { - self.require_artifact_capacity()?; + self.require_artifact_capacity(intent.layout.spread_mode)?; } if !self.document.publication_footnote_index_is_complete() { diff --git a/crates/rito-core/src/runtime/reader_session/session/request.rs b/crates/rito-core/src/runtime/reader_session/session/request.rs index bf679dfc8..1ac2a56a4 100644 --- a/crates/rito-core/src/runtime/reader_session/session/request.rs +++ b/crates/rito-core/src/runtime/reader_session/session/request.rs @@ -28,10 +28,9 @@ impl ReaderSession { "positioned glyph output is not available in the reader protocol yet", )); } - self.require_artifact_capacity()?; - let render_ratio = request.layout.render_ratio; let layout = layout_config(request.layout)?; + self.require_artifact_capacity(layout.spread_mode)?; self.render_ratio = render_ratio; let locator = runtime_locator(request.locator)?; let expected_visible_artifact_id = self.begin_foreground_request(request.request_id); @@ -56,7 +55,7 @@ impl ReaderSession { ) -> Result { self.validate_request_identity(request.session_id, request.request_id, "adjacent")?; validate_external_request_id(request.from_artifact_id, "fromArtifactId")?; - self.require_artifact_capacity()?; + self.require_artifact_capacity(self.artifact_spread_mode(request.from_artifact_id))?; let source = self .artifacts @@ -157,7 +156,7 @@ impl ReaderSession { ) -> Result { self.validate_request_identity(request.session_id, request.request_id, "peek")?; validate_external_request_id(request.from_artifact_id, "fromArtifactId")?; - self.require_artifact_capacity()?; + self.require_artifact_capacity(self.artifact_spread_mode(request.from_artifact_id))?; let source = self .artifacts .get(&request.from_artifact_id) @@ -196,7 +195,7 @@ impl ReaderSession { ) -> Result { self.validate_request_identity(request.session_id, request.request_id, "peek")?; validate_external_request_id(request.from_artifact_id, "fromArtifactId")?; - self.require_artifact_capacity()?; + self.require_artifact_capacity(self.artifact_spread_mode(request.from_artifact_id))?; let source = self .artifacts .get(&request.from_artifact_id) diff --git a/crates/rito-core/src/runtime/reader_session/tests.rs b/crates/rito-core/src/runtime/reader_session/tests.rs index ecf68fd63..0158ace00 100644 --- a/crates/rito-core/src/runtime/reader_session/tests.rs +++ b/crates/rito-core/src/runtime/reader_session/tests.rs @@ -1678,6 +1678,7 @@ fn reader_layout(config: &crate::layout::LayoutConfig) -> ReaderLayout { spread_mode: match config.spread_mode { crate::layout::SpreadMode::Single => ReaderSpreadMode::Single, crate::layout::SpreadMode::Double => ReaderSpreadMode::Double, + crate::layout::SpreadMode::Scrolled => ReaderSpreadMode::Scrolled, }, first_page_alone: config.first_page_alone, spread_gap: config.spread_gap, @@ -1872,7 +1873,7 @@ fn selection_geometry_spans_the_font_grid_box() { // Chromium's native selection rect spans the font box (the serving // font's whole-pixel ascent and descent), not the declared line box: // Tinos at 32px is 29 + 7 = 36 tall on a 48px fixed line-height. - let chapter: &[u8] = br#"

HELLO GRID WORLD

"#; + let chapter: &[u8] = br#"

HELLO GRID WORLD

"#; let epub = crate::runtime::tests::fixture::fixture_epub_with_chapter_and_stylesheet( chapter, ".sel { font-size: 32px; line-height: 48px; }", diff --git a/crates/rito-core/src/runtime/reader_session/types.rs b/crates/rito-core/src/runtime/reader_session/types.rs index ea27ad2f9..193f2c7e1 100644 --- a/crates/rito-core/src/runtime/reader_session/types.rs +++ b/crates/rito-core/src/runtime/reader_session/types.rs @@ -12,6 +12,20 @@ pub const READER_EXTERNAL_ID_MAX: u64 = i64::MAX as u64; pub enum ReaderSpreadMode { Single, Double, + /// Each chapter one continuous strip read through windows the size of + /// the viewport, every window its own spread. A book whose pages + /// progress right to left scrolls leftwards, others down + /// ([`ReaderScrollAxis`]); a chapter whose blocks run across the book's + /// axis (horizontal text in a leftward book, vertical in a downward + /// one) is paginated instead, each page one window. Window `k` of a + /// strip covers the chapter's CSS block range `[k·L, (k+1)·L)` from its + /// block start (`L` the viewport's height, or its width for a leftward + /// strip; the last window ends at the chapter's extent) and the device + /// pixels `[D(k), D(k+1))` along the axis, `D(k) = floor(k·L·ratio + + /// 0.5)`, the last ending at `floor(extent·ratio + 0.5)`: the windows + /// tile the chapter. A leftward strip starts at the right edge of its + /// first window. + Scrolled, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -338,6 +352,17 @@ pub struct ReaderFontRef { pub weight: u16, pub shape_fingerprint: String, pub byte_length: u64, + /// OpenType features the face turns on wherever its family paints: + /// the same bytes registered again under a family of their own (the + /// vertical forms a vertical line's upright glyphs take). + pub features: Vec, +} + +/// One OpenType feature setting: its four-letter tag and value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReaderFontFeature { + pub tag: String, + pub value: u32, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -670,6 +695,13 @@ pub struct ReaderArtifact { /// revision (the revision holds its complete page table from the /// moment it exists) and absent on chapter-local artifacts. pub book_page_count: Option, + /// Where the line the artifact's locator lands on starts in its + /// scrolled window's display list, in CSS pixels from the window's + /// block start (its top, or its right edge for a leftward strip): the + /// window is shown from here to put that line at the block start of the + /// viewport's content box. Absent for a spread of pages, for a page + /// shown as a window, and for a window whose locator names no line. + pub target_block_offset: Option, pub navigation: ReaderNavigation, pub text_profile: ReaderTextRenderingProfile, pub display_list: ReaderDisplayList, diff --git a/crates/rito-core/src/runtime/reader_session/vertical_tests.rs b/crates/rito-core/src/runtime/reader_session/vertical_tests.rs new file mode 100644 index 000000000..890d5246c --- /dev/null +++ b/crates/rito-core/src/runtime/reader_session/vertical_tests.rs @@ -0,0 +1,446 @@ +//! A vertical-rl page's records leave the session where its columns are +//! drawn: its hits, carets and selection rects in the artifact's +//! display-list space, the same space its pixels are in, paged or scrolled. +//! A scrolled window's records also move with its pixels by the device rows +//! it is drawn from, at any ratio. + +use crate::render::{DisplayCommand, DisplayTextCommand}; +use crate::runtime::tests::fixture::{long_paragraphs_fixture_epub, vertical_book_fixture_epub}; +use crate::runtime::tests::pinned_font_policy_fixtures::{bundled_font, face, policy}; +use crate::runtime::RuntimePinnedFontGenericRole; + +use super::{tests::request, *}; + +const SESSION: u64 = 733; + +fn open(publication: Vec) -> ReaderSession { + ReaderSession::open_owned_with_pinned_font_policy( + SESSION, + publication, + policy(vec![face( + bundled_font("SourceHanSerifCN-Regular.otf"), + RuntimePinnedFontGenericRole::Serif, + Some("zh"), + )]), + ) + .expect("reader session opens") +} + +fn at(href: &str, request_id: u64, mode: ReaderSpreadMode, ratio: f64) -> ReaderArtifactRequest { + let mut request = request(SESSION, request_id, href); + request.layout.spread_mode = mode; + request.layout.render_ratio = ratio; + request +} + +/// The columns chapter's artifact at `ratio`, shown first, and its spread +/// `spread` peeked from it. +fn columns(mode: ReaderSpreadMode, ratio: f64, spread: u32) -> (ReaderSession, ReaderArtifact) { + let mut session = open(vertical_book_fixture_epub()); + let href = session.publication().spine[1].href.clone(); + let first = session + .request_artifact(at(&href, 1, mode, ratio)) + .expect("columns artifact"); + super::tests::adopt_initial(&mut session, SESSION, first.artifact_id); + let artifact = session + .peek_spread(ReaderSpreadRequest { + session_id: SESSION, + request_id: 2, + from_artifact_id: first.artifact_id, + spread_index: first.local_spread_index + spread, + }) + .expect("spread publishes"); + (session, artifact) +} + +/// Every run of text the artifact shows whole, in its display-list space, +/// clear of its block-start edge: a window also paints what comes near it, +/// which its clip cuts away, and a line across that edge is recorded by the +/// window it starts in. +fn painted_runs(session: &ReaderSession, artifact: &ReaderArtifact) -> Vec { + let (commands, (x, y)) = session + .artifact_paint(artifact.artifact_id) + .expect("artifact paints"); + let ratio = session.render_ratio(); + let page = &artifact.pages[0]; + commands + .into_iter() + .filter_map(|command| match command { + DisplayCommand::PaintText(mut text) if !text.text.trim().is_empty() => { + text.rect.x -= x / ratio; + text.rect.y -= y / ratio; + let clear = 8.0; + let inside = text.rect.x >= 0.0 + && text.rect.y >= 0.0 + && text.rect.x + text.rect.width <= page.width + && text.rect.y + text.rect.height <= page.height; + let off_the_edge = + text.rect.y >= clear && text.rect.x + text.rect.width <= page.width - clear; + (inside && off_the_edge).then_some(text) + } + _ => None, + }) + .collect() +} + +fn records(session: &ReaderSession, artifact: &ReaderArtifact) -> ReaderPageRecords { + session + .read_page(artifact.artifact_id, artifact.pages[0].page_index) + .expect("page records") +} + +fn contains(rect: &ReaderRect, x: f64, y: f64) -> bool { + (rect.x..=rect.x + rect.width).contains(&x) && (rect.y..=rect.y + rect.height).contains(&y) +} + +/// Each painted run sits inside a text hit holding its text, and every hit +/// of more than one character is a column, taller than wide. +fn assert_hits_cover_the_columns(session: &ReaderSession, artifact: &ReaderArtifact) { + let painted = painted_runs(session, artifact); + assert!(!painted.is_empty(), "the page paints text"); + let hits: Vec<_> = records(session, artifact) + .hits + .into_iter() + .filter(|hit| hit.kind == ReaderHitKind::Text) + .collect(); + for run in &painted { + let (x, y) = ( + run.rect.x + run.rect.width / 2.0, + run.rect.y + run.rect.height / 2.0, + ); + assert!( + hits.iter() + .any(|hit| contains(&hit.bounds, x, y) && hit.text.contains(&*run.text)), + "{:?} at {:?} has no hit over it at ratio {}: {:?}", + run.text, + run.rect, + session.render_ratio(), + hits.iter().map(|hit| hit.bounds).collect::>() + ); + } + for hit in hits.iter().filter(|hit| hit.text.chars().count() > 1) { + assert!( + hit.bounds.height > hit.bounds.width, + "{:?} is a column: {:?}", + hit.text, + hit.bounds + ); + } +} + +fn interact( + session: &mut ReaderSession, + artifact: &ReaderArtifact, + query: ReaderTextInteractionQuery, +) -> ReaderTextInteractionResult { + session + .resolve_text_interaction(ReaderTextInteractionRequest { + session_id: SESSION, + artifact_id: artifact.artifact_id, + query, + every_page: false, + }) + .expect("interaction resolves") + .result +} + +/// A point in the top quarter of a painted glyph's em box takes the caret +/// before that glyph: a segment across its column at the glyph's top, on a +/// vertical line. +fn assert_a_caret_runs_across_its_column(session: &mut ReaderSession, artifact: &ReaderArtifact) { + let painted = painted_runs(session, artifact); + let run = &painted[painted.len() / 2]; + let em = run.rect.width; + let (x, y) = (run.rect.x + em / 2.0, run.rect.y + em / 4.0); + let point = ReaderTextPoint { + page_index: artifact.pages[0].page_index, + x, + y, + }; + let ReaderTextInteractionResult::Caret(caret) = interact( + session, + artifact, + ReaderTextInteractionQuery::Caret { point }, + ) else { + panic!("a caret at {x}, {y}"); + }; + assert_eq!(caret.writing_mode, ReaderWritingMode::VerticalRl); + let geometry = caret.geometry.expect("the artifact draws the caret"); + assert_eq!(geometry.height, 0.0, "{geometry:?} runs across the column"); + assert!( + geometry.x <= x && x <= geometry.x + geometry.width, + "{geometry:?} spans the column at {x}" + ); + assert!( + (geometry.y - run.rect.y).abs() < 0.01, + "{geometry:?} sits at the glyph's top {}", + run.rect.y + ); +} + +/// A selection dragged from one column's head to a glyph three columns on +/// covers each glyph between with column-shaped rects. +fn assert_a_selection_is_drawn_as_columns(session: &mut ReaderSession, artifact: &ReaderArtifact) { + let painted = painted_runs(session, artifact); + let page_index = artifact.pages[0].page_index; + let first = &painted[0]; + let em = first.rect.width; + let mut columns: Vec = painted.iter().map(|run| run.rect.x).collect(); + columns.sort_by(|a, b| b.total_cmp(a)); + columns.dedup(); + let last_x = columns[3.min(columns.len() - 1)]; + let point = |x: f64, y: f64| ReaderTextPoint { page_index, x, y }; + let anchor = match interact( + session, + artifact, + ReaderTextInteractionQuery::Caret { + point: point(first.rect.x + em / 2.0, first.rect.y + em / 4.0), + }, + ) { + ReaderTextInteractionResult::Caret(caret) => caret.address, + other => panic!("an anchor caret: {other:?}"), + }; + let result = interact( + session, + artifact, + ReaderTextInteractionQuery::RangeToPoint { + anchor, + focus: point(last_x + em / 2.0, first.rect.y + em * 2.75), + }, + ); + let ReaderTextInteractionResult::Selection(selection) = result else { + panic!("a selection"); + }; + let rects = &selection.selection.rects; + assert!(rects.len() >= 3, "the selection spans columns: {rects:?}"); + for rect in rects + .iter() + .filter(|rect| rect.end_char_index - rect.start_char_index > 1) + { + assert!( + rect.bounds.height > rect.bounds.width, + "{:?} is a column", + rect.bounds + ); + } + let covered = |x: f64, y: f64| rects.iter().any(|rect| contains(&rect.bounds, x, y)); + assert!(covered(first.rect.x + em / 2.0, first.rect.y + em / 2.0)); +} + +#[test] +fn a_vertical_pages_records_lie_on_the_columns_it_paints() { + let (mut session, artifact) = columns(ReaderSpreadMode::Single, 1.0, 0); + assert_hits_cover_the_columns(&session, &artifact); + assert_a_caret_runs_across_its_column(&mut session, &artifact); + assert_a_selection_is_drawn_as_columns(&mut session, &artifact); +} + +#[test] +fn a_leftward_windows_records_lie_on_the_columns_it_paints_at_any_ratio() { + for ratio in [1.0, 1.5, 2.625] { + let (mut session, artifact) = columns(ReaderSpreadMode::Scrolled, ratio, 1); + assert_hits_cover_the_columns(&session, &artifact); + assert_a_caret_runs_across_its_column(&mut session, &artifact); + assert_a_selection_is_drawn_as_columns(&mut session, &artifact); + } +} + +#[test] +fn a_horizontal_caret_has_no_width_and_reads_horizontally() { + let mut session = open(long_paragraphs_fixture_epub()); + let href = session.publication().spine[0].href.clone(); + let artifact = session + .request_artifact(at(&href, 1, ReaderSpreadMode::Single, 1.0)) + .expect("artifact"); + let run = painted_runs(&session, &artifact)[0].clone(); + let point = ReaderTextPoint { + page_index: artifact.pages[0].page_index, + x: run.rect.x + 1.0, + y: run.rect.y + run.rect.height / 2.0, + }; + let ReaderTextInteractionResult::Caret(caret) = interact( + &mut session, + &artifact, + ReaderTextInteractionQuery::Caret { point }, + ) else { + panic!("a caret"); + }; + assert_eq!(caret.writing_mode, ReaderWritingMode::HorizontalTb); + let geometry = caret.geometry.expect("drawn"); + assert_eq!(geometry.width, 0.0); + assert!(geometry.height > 0.0); +} + +/// The tops of the text hits a window records, in its display-list space. +fn hit_tops(session: &ReaderSession, artifact: &ReaderArtifact) -> Vec { + records(session, artifact) + .hits + .into_iter() + .filter(|hit| hit.kind == ReaderHitKind::Text) + .map(|hit| hit.bounds.y) + .collect() +} + +/// The first window of the chapter at `ratio`, and its windows `spreads`. +fn windows(ratio: f64, spreads: std::ops::Range) -> (ReaderSession, Vec) { + let mut session = open(long_paragraphs_fixture_epub()); + let href = session.publication().spine[0].href.clone(); + let first = session + .request_artifact(at(&href, 1, ReaderSpreadMode::Scrolled, ratio)) + .expect("first window"); + super::tests::adopt_initial(&mut session, SESSION, first.artifact_id); + let windows = spreads + .map(|spread| { + session + .peek_spread(ReaderSpreadRequest { + session_id: SESSION, + request_id: 2 + u64::from(spread), + from_artifact_id: first.artifact_id, + spread_index: spread, + }) + .expect("window publishes") + }) + .collect(); + (session, windows) +} + +#[test] +fn a_downward_windows_records_move_with_its_pixels_by_whole_device_rows() { + // 640 × 4/3 is no whole number of device rows: every window but the + // first is drawn from a row that is not its CSS start, and moved onto + // its own origin by the rows. At ratio 1 the rows are the CSS starts, + // and the layout is the same at every ratio. + let ratio = 4.0 / 3.0; + let (exact, exact_windows) = windows(1.0, 1..4); + let (session, windows) = windows(ratio, 1..4); + for (spread, (window, exact_window)) in (1u32..).zip(windows.iter().zip(&exact_windows)) { + let length = exact_window.pages[0].height; + let start = f64::from(spread) * length; + let drawn_from = (start * ratio + 0.5).floor() / ratio; + if spread == 1 { + assert_ne!(drawn_from, start, "window 1 starts off the device grid"); + } + let tops = hit_tops(&session, window); + let exact_tops = hit_tops(&exact, exact_window); + assert!(!tops.is_empty(), "window {spread} records text"); + assert_eq!(tops.len(), exact_tops.len()); + for (top, exact_top) in tops.iter().zip(&exact_tops) { + assert!( + (top + drawn_from - (exact_top + start)).abs() < 1e-6, + "window {spread}: a hit at {top} is not where its line is drawn ({})", + exact_top + start - drawn_from + ); + } + } +} + +/// The engine's side of `tools/corpus-oracle/vertical-rects-probe.mjs`: the +/// first page of the vertical fixture's columns, as the probe lays its +/// paragraphs out in Chromium, each character's range rects and the caret +/// before it, in the page's display-list space and the probe's JSON shape. +/// `tools/corpus-oracle/vertical-rects-compare.mjs` reads the two. +#[test] +#[ignore = "writes the engine's vertical records for the Chromium comparison"] +fn dump_vertical_records() { + let path = std::env::var("RITO_VERTICAL_RECORDS_DUMP") + .expect("RITO_VERTICAL_RECORDS_DUMP names the file to write"); + let (mut session, artifact) = columns(ReaderSpreadMode::Single, 1.0, 0); + let href = session.publication().spine[1].href.clone(); + let rect = |bounds: &ReaderRect| { + serde_json::json!({ + "x": bounds.x, "y": bounds.y, "width": bounds.width, "height": bounds.height, + }) + }; + let mut paragraphs = Vec::new(); + for paragraph in 0..3u32 { + let mut characters = Vec::new(); + for offset in 0..144u64 { + let point = |text_offset| ReaderSourcePoint { + node_path: vec![paragraph, 0], + text_offset, + }; + let rects: Vec = session + .resolve_exact_source_range(ReaderExactSourceRangeRequest { + session_id: SESSION, + artifact_id: artifact.artifact_id, + href: href.clone(), + range: ReaderSourceRange { + start: point(offset), + end: point(offset + 1), + }, + every_page: false, + }) + .expect("range resolves") + .rects + .into_iter() + .map(|placed| placed.bounds) + .collect(); + let caret = rects.first().and_then(|first| { + let at = ReaderTextPoint { + page_index: artifact.pages[0].page_index, + x: first.x + first.width / 2.0, + y: first.y + first.height / 4.0, + }; + match interact( + &mut session, + &artifact, + ReaderTextInteractionQuery::Caret { point: at }, + ) { + ReaderTextInteractionResult::Caret(caret) => caret.geometry, + _ => None, + } + }); + characters.push(serde_json::json!({ + "offset": offset, + "rects": rects.iter().map(rect).collect::>(), + "caret": caret.iter().map(|geometry| serde_json::json!({ + "x": geometry.x, "y": geometry.y, + "width": geometry.width, "height": geometry.height, + })).collect::>(), + })); + } + paragraphs + .push(serde_json::json!({ "id": format!("p{paragraph}"), "characters": characters })); + } + let dump = + serde_json::json!({ "width": 420, "height": 640, "margin": 24, "rects": paragraphs }); + std::fs::write( + path, + serde_json::to_string_pretty(&dump).expect("dump encodes"), + ) + .expect("dump writes"); +} + +/// A vertical page names its face's vertical forms among its fonts: the +/// same bytes as the face, under a family of their own, with `vert` on. +#[test] +fn a_vertical_pages_fonts_name_their_faces_vertical_forms() { + let (_session, artifact) = columns(ReaderSpreadMode::Single, 1.0, 0); + let forms = artifact + .fonts + .iter() + .find(|font| font.family.starts_with("__RitoVert_")) + .expect("the page paints in a vertical-forms family"); + assert_eq!( + forms.features, + vec![ReaderFontFeature { + tag: "vert".to_owned(), + value: 1, + }] + ); + let face = artifact + .fonts + .iter() + .find(|font| forms.family == format!("__RitoVert_{}", font.family)); + if let Some(face) = face { + assert_eq!( + (&face.href, face.byte_length), + (&forms.href, forms.byte_length) + ); + assert!(face.features.is_empty()); + } + assert!(artifact + .resources + .iter() + .any(|resource| resource.href == forms.href)); +} diff --git a/crates/rito-core/src/runtime/reader_session/wire/decode.rs b/crates/rito-core/src/runtime/reader_session/wire/decode.rs index 57366c132..058b9e230 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/decode.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/decode.rs @@ -485,6 +485,7 @@ pub(super) fn spread_mode(value: u32) -> Result { match value { 0 => Ok(ReaderSpreadMode::Single), 1 => Ok(ReaderSpreadMode::Double), + 2 => Ok(ReaderSpreadMode::Scrolled), value => Err(invalid(format!("unknown spread mode: {value}"))), } } diff --git a/crates/rito-core/src/runtime/reader_session/wire/decode/artifact.rs b/crates/rito-core/src/runtime/reader_session/wire/decode/artifact.rs index e53845792..d9b395dd6 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/decode/artifact.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/decode/artifact.rs @@ -1,8 +1,8 @@ use super::{locator, source_point, text_profile}; use crate::runtime::reader_session::wire::primitives::{invalid, Reader, MAX_SEMANTIC_DEPTH}; use crate::runtime::reader_session::{ - ReaderAdjacentAvailability, ReaderArtifact, ReaderDisplayList, ReaderError, ReaderFontRef, - ReaderHitEntry, ReaderHitKind, ReaderLocatorMatch, ReaderNavigation, ReaderPage, + ReaderAdjacentAvailability, ReaderArtifact, ReaderDisplayList, ReaderError, ReaderFontFeature, + ReaderFontRef, ReaderHitEntry, ReaderHitKind, ReaderLocatorMatch, ReaderNavigation, ReaderPage, ReaderPageRecords, ReaderRect, ReaderResourceKind, ReaderResourceRef, ReaderSemanticNode, ReaderSemanticRole, ReaderTextRunOffset, READER_PROTOCOL_VERSION, }; @@ -31,6 +31,9 @@ pub(super) fn body(reader: &mut Reader<'_>) -> Result) -> Result, ReaderError> { weight: reader.u16()?, shape_fingerprint: reader.string("font shape fingerprint")?, byte_length: reader.u64()?, + features: reader.collection("font features", |reader| { + Ok(ReaderFontFeature { + tag: reader.string("font feature tag")?, + value: reader.u32()?, + }) + })?, }) }) }) diff --git a/crates/rito-core/src/runtime/reader_session/wire/encode.rs b/crates/rito-core/src/runtime/reader_session/wire/encode.rs index dba4083dc..d3914a11f 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/encode.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/encode.rs @@ -444,6 +444,7 @@ pub(super) const fn spread_mode(value: ReaderSpreadMode) -> u32 { match value { ReaderSpreadMode::Single => 0, ReaderSpreadMode::Double => 1, + ReaderSpreadMode::Scrolled => 2, } } diff --git a/crates/rito-core/src/runtime/reader_session/wire/encode/artifact.rs b/crates/rito-core/src/runtime/reader_session/wire/encode/artifact.rs index 4ff3f1de6..80853e966 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/encode/artifact.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/encode/artifact.rs @@ -38,6 +38,9 @@ pub(super) fn body(writer: &mut Writer, value: &ReaderArtifact) -> Result<(), Re writer.u32(*count); Ok(()) })?; + writer.option(value.target_block_offset.as_ref(), |writer, offset| { + writer.f64(*offset, "target block offset") + })?; writer.u32(adjacent_availability(value.navigation.previous)); writer.u32(adjacent_availability(value.navigation.next)); writer.u32(text_profile(value.text_profile)); @@ -84,6 +87,11 @@ fn fonts(writer: &mut Writer, values: &[ReaderFontRef]) -> Result<(), ReaderErro writer.u16(value.weight); writer.string(&value.shape_fingerprint, "font shape fingerprint")?; writer.u64(value.byte_length); + writer.count(value.features.len(), "font feature count")?; + for feature in &value.features { + writer.string(&feature.tag, "font feature tag")?; + writer.u32(feature.value); + } Ok(()) })?; } diff --git a/crates/rito-core/src/runtime/reader_session/wire/interaction.rs b/crates/rito-core/src/runtime/reader_session/wire/interaction.rs index e0a8b472e..f35245cd3 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/interaction.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/interaction.rs @@ -23,9 +23,9 @@ pub(super) use navigation::{ use super::primitives::invalid; use crate::runtime::reader_session::{ - ReaderAnnotationLevel, ReaderCaretAffinity, ReaderError, ReaderLocatorMatch, + ReaderAnnotationLevel, ReaderCaretAffinity, ReaderError, ReaderLocatorMatch, ReaderScrollAxis, ReaderSelectionBoundary, ReaderSelectionGranularity, ReaderSelectionMovement, - ReaderTextInteractionUnavailableReason, + ReaderTextInteractionUnavailableReason, ReaderWritingMode, }; const MOVEMENTS: [ReaderSelectionMovement; 19] = [ @@ -76,6 +76,7 @@ const LOCATOR_MATCHES: [ReaderLocatorMatch; 5] = [ ReaderLocatorMatch::Progression, ReaderLocatorMatch::Href, ]; +const SCROLL_AXES: [ReaderScrollAxis; 2] = [ReaderScrollAxis::Down, ReaderScrollAxis::Left]; const ORDERINGS: [std::cmp::Ordering; 3] = [ std::cmp::Ordering::Less, std::cmp::Ordering::Equal, @@ -86,6 +87,10 @@ const AFFINITIES: [ReaderCaretAffinity; 2] = [ ReaderCaretAffinity::Upstream, ReaderCaretAffinity::Downstream, ]; +const WRITING_MODES: [ReaderWritingMode; 2] = [ + ReaderWritingMode::HorizontalTb, + ReaderWritingMode::VerticalRl, +]; const GRANULARITIES: [ReaderSelectionGranularity; 2] = [ ReaderSelectionGranularity::Word, ReaderSelectionGranularity::Paragraph, diff --git a/crates/rito-core/src/runtime/reader_session/wire/interaction/decode.rs b/crates/rito-core/src/runtime/reader_session/wire/interaction/decode.rs index db4306924..3d83d0654 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/interaction/decode.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/interaction/decode.rs @@ -7,7 +7,7 @@ use super::super::{ }; use super::{ from_tag, AFFINITIES, ANNOTATION_LEVELS, BOUNDARIES, GRANULARITIES, MOVEMENTS, - UNAVAILABLE_REASONS, + UNAVAILABLE_REASONS, WRITING_MODES, }; use crate::runtime::reader_session::{ wire::primitives::invalid, ReaderAnnotationQuery, ReaderAnnotationRequest, @@ -212,9 +212,11 @@ fn caret(reader: &mut Reader<'_>) -> Result { Ok(ReaderCaretGeometry { x: reader.f64("caret x")?, y: reader.f64("caret y")?, + width: reader.f64("caret width")?, height: reader.f64("caret height")?, }) })?, + writing_mode: from_tag(&WRITING_MODES, reader.u8()?, "caret writing mode")?, href: reader.string("caret href")?, source_point: source_point(reader)?, }) diff --git a/crates/rito-core/src/runtime/reader_session/wire/interaction/encode.rs b/crates/rito-core/src/runtime/reader_session/wire/interaction/encode.rs index 5f2129afd..cb91fabb9 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/interaction/encode.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/interaction/encode.rs @@ -7,7 +7,7 @@ use super::super::{ }; use super::{ tag_of, AFFINITIES, ANNOTATION_LEVELS, BOUNDARIES, GRANULARITIES, MOVEMENTS, - UNAVAILABLE_REASONS, + UNAVAILABLE_REASONS, WRITING_MODES, }; use crate::runtime::reader_session::{ ReaderAnnotationQuery, ReaderAnnotationRequest, ReaderAnnotationResponse, ReaderCaret, @@ -184,8 +184,10 @@ fn caret(writer: &mut Writer, value: &ReaderCaret) -> Result<(), ReaderError> { writer.option(value.geometry.as_ref(), |writer, geometry| { writer.f64(geometry.x, "caret x")?; writer.f64(geometry.y, "caret y")?; + writer.f64(geometry.width, "caret width")?; writer.f64(geometry.height, "caret height") })?; + writer.u8(tag_of(&WRITING_MODES, &value.writing_mode)); writer.string(&value.href, "caret href")?; source_point(writer, &value.source_point) }) diff --git a/crates/rito-core/src/runtime/reader_session/wire/interaction/navigation.rs b/crates/rito-core/src/runtime/reader_session/wire/interaction/navigation.rs index e9b1ca9c7..6a761743f 100644 --- a/crates/rito-core/src/runtime/reader_session/wire/interaction/navigation.rs +++ b/crates/rito-core/src/runtime/reader_session/wire/interaction/navigation.rs @@ -7,7 +7,7 @@ use super::super::{ READER_NAVIGATION_REQUEST_WIRE_MAGIC, READER_NAVIGATION_RESPONSE_WIRE_MAGIC, READER_WIRE_VERSION, }; -use super::{from_tag, tag_of, LOCATOR_MATCHES, ORDERINGS}; +use super::{from_tag, tag_of, LOCATOR_MATCHES, ORDERINGS, SCROLL_AXES}; use crate::runtime::reader_session::{ ReaderChapterPages, ReaderError, ReaderLocation, ReaderNavigationQuery, ReaderNavigationRequest, ReaderNavigationResult, ReaderRevisionLayout, ReaderSpreadPages, @@ -117,11 +117,15 @@ pub(in crate::runtime::reader_session::wire) fn encode_result( page_index, drawn, matched_by, + block_offset, } => { writer.u8(0); writer.u32(*page_index); writer.bool(*drawn); writer.u8(tag_of(&LOCATOR_MATCHES, matched_by)); + writer.option(block_offset.as_ref(), |writer, offset| { + writer.f64(*offset, "located block offset") + })?; } ReaderLocation::NotLaidOut => writer.u8(1), ReaderLocation::Unavailable => writer.u8(2), @@ -154,6 +158,9 @@ pub(in crate::runtime::reader_session::wire) fn decode_result( page_index: reader.u32()?, drawn: reader.bool("page drawn")?, matched_by: from_tag(&LOCATOR_MATCHES, reader.u8()?, "locator match")?, + block_offset: reader.option("located block offset", |reader| { + reader.f64("located block offset") + })?, }, 1 => ReaderLocation::NotLaidOut, 2 => ReaderLocation::Unavailable, @@ -179,6 +186,9 @@ fn write_layout(writer: &mut Writer, value: &ReaderRevisionLayout) -> Result<(), writer.u32(chapter.spine_index); writer.u32(chapter.start_page); writer.u32(chapter.end_page); + writer.option(chapter.extent.as_ref(), |writer, extent| { + writer.f64(*extent, "chapter extent") + })?; } writer.count(value.toc_targets.len(), "layout toc target count")?; for target in &value.toc_targets { @@ -193,7 +203,10 @@ fn write_layout(writer: &mut Writer, value: &ReaderRevisionLayout) -> Result<(), write_optional_u32(writer, *entry)?; } writer.bool(value.complete); - Ok(()) + writer.option(value.scroll_axis.as_ref(), |writer, axis| { + writer.u8(tag_of(&SCROLL_AXES, axis)); + Ok(()) + }) } fn read_layout(reader: &mut Reader<'_>) -> Result { @@ -210,6 +223,7 @@ fn read_layout(reader: &mut Reader<'_>) -> Result) -> Result ReaderArtifact { height: 1_194.25, book_page_index: Some(140), book_page_count: Some(312), + target_block_offset: Some(377.640625), navigation: ReaderNavigation { previous: ReaderAdjacentAvailability::Available, next: ReaderAdjacentAvailability::ChapterBoundary, @@ -700,6 +701,10 @@ fn artifact_fixture() -> ReaderArtifact { weight: 650, shape_fingerprint: "sha256:abcd".into(), byte_length: (1u64 << 40) + 7, + features: vec![ReaderFontFeature { + tag: "vert".into(), + value: 1, + }], }], pages: vec![page_fixture()], } diff --git a/crates/rito-core/src/runtime/revision_fonts.rs b/crates/rito-core/src/runtime/revision_fonts.rs index 156efd5b6..d3d964e19 100644 --- a/crates/rito-core/src/runtime/revision_fonts.rs +++ b/crates/rito-core/src/runtime/revision_fonts.rs @@ -22,6 +22,7 @@ impl RuntimeDocument { shape_fingerprint: face.shape_fingerprint, byte_length: face.byte_length, source_order: face.source_order, + features: Vec::new(), }) .collect(); let first_host_order = faces @@ -30,6 +31,31 @@ impl RuntimeDocument { .max() .unwrap_or_default(); faces.extend(self.pinned_font_policy.required_faces(first_host_order)); + // Every face also paints in its vertical forms and in the + // narrower forms a combined upright string compresses into; an + // artifact lists a variant only where its display list draws + // with it. + let mut variants: Vec<_> = faces + .iter() + .map(|face| RuntimeRequiredFontFace { + family: crate::fragment_paint::vertical_forms_family(&face.family), + features: vec![("vert".to_owned(), 1)], + ..face.clone() + }) + .collect(); + for forms in [ + rito_fragment::CombinedForms::Half, + rito_fragment::CombinedForms::Third, + rito_fragment::CombinedForms::Quarter, + ] { + let feature = forms.feature().unwrap_or_default(); + variants.extend(faces.iter().map(|face| RuntimeRequiredFontFace { + family: crate::fragment_paint::combined_forms_family(forms, &face.family), + features: vec![(feature.to_owned(), 1)], + ..face.clone() + })); + } + faces.extend(variants); faces }) } diff --git a/crates/rito-core/src/runtime/scroll_strip.rs b/crates/rito-core/src/runtime/scroll_strip.rs new file mode 100644 index 000000000..74128f267 --- /dev/null +++ b/crates/rito-core/src/runtime/scroll_strip.rs @@ -0,0 +1,321 @@ +//! One chapter laid out as a continuous strip, painted and recorded window +//! by window: the scrolled reading mode's pages of that chapter (see +//! [`crate::fragment_window`]). + +use std::{ops::Range, rc::Rc, sync::Arc}; + +use rito_fragment::{CancelFlag, Fragment, LineText}; + +use crate::epub::{EpubError, EpubResult}; +use crate::fragment_bridge::ChapterFormattingTree; +use crate::fragment_pagination::{chapter_is_vertical, layout_chapter_strip, paint_chapter_page}; +use crate::fragment_paint::FragmentPaintContext; +use crate::fragment_window::{window_paint_tree, window_record_tree, ScrollAxis, ScrollWindows}; +use crate::layout::LayoutConfig; +use crate::render::contract::{ReaderBlockPaint, ReaderColor}; +use crate::render::{display_rect, DisplayCommand}; + +use super::fragment_frame::{paint_rect_command, RuntimeFragmentEngine}; + +#[cfg(test)] +mod test_views; + +/// A chapter's strip with what painting and recording its windows reads. +pub(super) struct ChapterStrip { + built: Rc, + /// The paint alias of every face the engine shapes with. + faces: Rc<[Arc]>, + /// The strip, its root at the content origin; its lines hold their + /// text when it was laid out with it, and a window's text is laid out + /// for it otherwise ([`Self::window_text`]). + root: Fragment, + windows: ScrollWindows, + page_width: f64, + page_height: f64, + content_width: f64, + /// The content box's height on a page, which images and `vh` resolve + /// against. + content_height: f64, + /// The strip's block length: its height, or its width for a chapter + /// that runs leftwards. + strip_length: f64, + /// The content origin in the chapter: the left and top margins. + origin: (f64, f64), + /// Where the strip starts along its block axis: the top margin, or the + /// right margin for a chapter that runs leftwards. + block_start: f64, +} + +impl std::fmt::Debug for ChapterStrip { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The strip's fragments are the whole chapter; its windows name it. + formatter + .debug_struct("ChapterStrip") + .field("windows", &self.windows) + .finish_non_exhaustive() + } +} + +impl ChapterStrip { + /// Lays the bridged chapter out as one strip under `config`, its windows + /// a page long along its block axis (down for horizontal text, leftwards + /// for vertical); without `line_text` its lines hold no text fragments, + /// as a page laid out for pagination holds none, and each window's are + /// laid out when it is read ([`Self::window_text`]). + pub(super) fn lay_out( + engine: &RuntimeFragmentEngine, + faces: Rc<[Arc]>, + built: Rc, + config: &LayoutConfig, + line_text: bool, + ) -> EpubResult { + let axis = ScrollAxis::of_chapter(chapter_is_vertical(&built.tree)); + let (content_width, content_height) = (config.content_width(), config.content_height()); + if content_width <= 0.0 || content_height <= 0.0 { + return Err(EpubError::new("page content box is empty")); + } + let root = layout_chapter_strip( + &engine.engine, + &built.tree, + content_width, + content_height, + if line_text { + LineText::All + } else { + LineText::Omitted + }, + &CancelFlag::new(), + )?; + // A vertical-rl strip is laid out in the swapped frame: its block + // length is its width, starting at the right margin. + let strip_length = root.rect().height; + let (length, block_start, block_end) = match axis { + ScrollAxis::Down => (config.page_height, config.margin_top, config.margin_bottom), + ScrollAxis::Left => (config.page_width, config.margin_right, config.margin_left), + }; + Ok(Self { + built, + faces, + root, + windows: ScrollWindows::new(length, block_start + strip_length + block_end, axis), + page_width: config.page_width, + page_height: config.page_height, + content_width, + content_height, + strip_length, + origin: (config.margin_left, config.margin_top), + block_start, + }) + } + + pub(super) fn windows(&self) -> ScrollWindows { + self.windows + } + + /// What paints window `window`, laid out again with its text: the strip + /// cut down to the fragments that reach the window, their lines holding + /// their text, in the strip's coordinates. Only the lines the cut keeps + /// are given their text, and the strip's geometry does not depend on + /// which lines hold it, which the layout is held to. + pub(super) fn window_text( + &self, + engine: &RuntimeFragmentEngine, + window: usize, + ) -> EpubResult { + let windows = window..window + 1; + // A line holds its text by where it starts, in the strip's own + // coordinates (the root's); a pixel of slack either side covers + // boxes that land on the layout grid after their lines decided + // where they start. + let line_text = match self.reaching(&self.root, &windows).line_top_span() { + Some((first, last)) => LineText::Within { + top: first - 1.0, + bottom: last + 1.0, + }, + None => LineText::Omitted, + }; + Ok(self.reaching(&self.lay_out_text(engine, line_text)?, &windows)) + } + + /// Every window's records with their text, from one layout of the whole + /// strip with it: what reading every window's text at once costs, as a + /// search does, without a layout per window. + pub(super) fn window_record_roots( + &self, + engine: &RuntimeFragmentEngine, + ) -> EpubResult> { + let root = self.lay_out_text(engine, LineText::All)?; + Ok((0..self.windows.count()) + .map(|window| self.record_root(&root, window)) + .collect()) + } + + /// The strip laid out again with the text `line_text` names, its + /// geometry held to the strip's. + fn lay_out_text( + &self, + engine: &RuntimeFragmentEngine, + line_text: LineText, + ) -> EpubResult { + let root = layout_chapter_strip( + &engine.engine, + &self.built.tree, + self.content_width, + self.content_height, + line_text, + &CancelFlag::new(), + )?; + if root.rect().height != self.strip_length { + return Err(EpubError::new( + "a chapter strip laid out with its text runs where it did not without", + )); + } + Ok(root) + } + + /// `root`, a layout of the strip, cut down to what can ink the run of + /// windows `windows`: whatever comes within a window of them. + fn reaching(&self, root: &Fragment, windows: &Range) -> Fragment { + window_paint_tree( + &self.built.tree, + Some(&self.built.node_paints), + root, + self.block_start, + self.windows.range(windows.start).start..self.windows.range(windows.end - 1).end, + self.windows.length(), + ) + } + + /// The run of windows `windows` (one, as a reader shows it) painted at + /// `ratio` from `text`, a layout of the strip that holds their lines' + /// text, in the chapter's coordinates, and where the run's top-left + /// corner sits in the chapter, in device pixels: the page wash, the + /// body's background over the whole strip (its tiles run on across + /// windows), and the content that reaches the windows, clipped to them. + pub(super) fn window_frame( + &self, + text: &Fragment, + windows: Range, + ratio: f64, + ) -> EpubResult<(Vec, (f64, f64))> { + let length = (self.windows.device_rows(windows.end - 1, ratio).end + - self.windows.device_rows(windows.start, ratio).start) + / ratio; + // A leftward run's top-left corner is its last window's. + let origin = match self.windows.axis() { + ScrollAxis::Down => self.windows.device_origin(windows.start, ratio), + ScrollAxis::Left => self.windows.device_origin(windows.end - 1, ratio), + }; + let (x, y) = (origin.0 / ratio, origin.1 / ratio); + let (margin_left, margin_top) = self.origin; + // As on a page, ink before the root box's inline start is cut at the + // initial containing block and the inline end bleeds to the page + // edge; along the block axis the run's own edges clip. + let (wash, body, clip) = match self.windows.axis() { + ScrollAxis::Down => ( + (0.0, y, self.page_width, length), + ( + margin_left, + margin_top, + self.content_width, + self.strip_length, + ), + (margin_left, y, self.page_width - margin_left, length), + ), + ScrollAxis::Left => ( + (x, 0.0, length, self.page_height), + ( + margin_left + self.content_width - self.strip_length, + margin_top, + self.strip_length, + self.content_height, + ), + (x, margin_top, length, self.page_height - margin_top), + ), + }; + let built = &self.built; + let mut commands = vec![paint_rect_command( + wash.0, + wash.1, + wash.2, + wash.3, + built.page_background.unwrap_or(ReaderColor::WHITE), + )]; + if let Some(paint) = &built.page_background_image { + commands.push(DisplayCommand::PaintBlock { + rect: display_rect(body.0, body.1, body.2, body.3), + paint: Box::new(ReaderBlockPaint { + background: Some(paint.clone()), + ..ReaderBlockPaint::default() + }), + border_box: None, + }); + } + commands.push(DisplayCommand::PushState); + commands.push(DisplayCommand::ClipRect { + rect: display_rect(clip.0, clip.1, clip.2, clip.3), + radius: None, + }); + commands.extend(self.content(text, &windows, ratio)?); + commands.push(DisplayCommand::PopState); + Ok((commands, origin)) + } + + /// What of `text` reaches the run of windows `windows`, painted at + /// `ratio` in the chapter's coordinates. + fn content( + &self, + text: &Fragment, + windows: &Range, + ratio: f64, + ) -> EpubResult> { + let (margin_left, margin_top) = self.origin; + let built = &self.built; + paint_chapter_page( + &built.tree, + &self.reaching(text, windows), + self.content_width, + margin_left, + margin_top, + FragmentPaintContext { + faces: Some(&self.faces), + node_paints: Some(&built.node_paints), + image_border_paints: Some(&built.image_border_paints), + list_markers: Some(&built.list_markers), + annotations: crate::fragment_paint::LineAnnotations { + ruby: Some(&built.ruby_annotation_runs), + emphasis: Some(&built.emphasis_marks), + }, + vertical_frame: None, + flow_item_sources: Some(&built.flow_item_sources), + ratio, + }, + self.windows.axis() == ScrollAxis::Left, + ) + } + + /// Window `window` as a page of the chapter: what it records, with its + /// root moved up by the window's place in the strip, so its fragments + /// sit in the window's content box as a page's sit in the page's. Its + /// lines hold their text when the strip was laid out with it. + pub(super) fn window_page_root(&self, window: usize) -> Fragment { + self.record_root(&self.root, window) + } + + /// [`Self::window_page_root`] from `text`, a layout of the strip that + /// holds the window's lines' text ([`Self::window_text`]). + pub(super) fn window_text_page_root(&self, text: &Fragment, window: usize) -> Fragment { + self.record_root(text, window) + } + + fn record_root(&self, root: &Fragment, window: usize) -> Fragment { + let range = self.windows.range(window); + match window_record_tree(root, self.block_start, range.clone()) { + Fragment::Box(mut root) => { + root.rect.y -= range.start; + Fragment::Box(root) + } + other => other, + } + } +} diff --git a/crates/rito-core/src/runtime/scroll_strip/test_views.rs b/crates/rito-core/src/runtime/scroll_strip/test_views.rs new file mode 100644 index 000000000..60283a0b0 --- /dev/null +++ b/crates/rito-core/src/runtime/scroll_strip/test_views.rs @@ -0,0 +1,64 @@ +//! What tests read of a strip: the strip itself, its windows lowered, and +//! its records whole and window by window. + +use std::ops::Range; + +use rito_fragment::Fragment; + +use super::ChapterStrip; +use crate::epub::{EpubError, EpubResult}; +use crate::render::{lower, shift, ImageSize, PrimitiveList}; +use crate::runtime::page_artifact::FragmentPageArtifact; + +impl ChapterStrip { + /// The strip as it was laid out, with its text when it was laid out + /// with it. + pub(in crate::runtime) fn whole(&self) -> &Fragment { + &self.root + } + + /// The content of the run of windows `windows` lowered at `ratio`, in the + /// chapter's coordinates and without the frame around it. + pub(in crate::runtime) fn lowered_content( + &self, + windows: Range, + ratio: f64, + images: &dyn Fn(&str) -> Option, + ) -> EpubResult { + let commands = self.content(&self.root, &windows, ratio)?; + lower(&commands, ratio, images).map_err(|error| EpubError::new(error.to_string())) + } + + /// The run of windows `windows` lowered at `ratio` onto its own origin: + /// its first device row is row 0. + pub(in crate::runtime) fn lowered_window( + &self, + windows: Range, + ratio: f64, + images: &dyn Fn(&str) -> Option, + ) -> EpubResult { + let (commands, (x, y)) = self.window_frame(&self.root, windows, ratio)?; + let mut lowered = + lower(&commands, ratio, images).map_err(|error| EpubError::new(error.to_string()))?; + shift(&mut lowered, -x, -y); + Ok(lowered) + } + + /// The whole strip's records, as one window over the chapter would hold + /// them. + pub(in crate::runtime) fn whole_artifact(&self) -> FragmentPageArtifact { + FragmentPageArtifact::build(0, &self.root, &self.built, self.origin.0, self.origin.1) + } + + /// Window `window`'s records, in CSS pixels from its top-left corner. + pub(in crate::runtime) fn window_artifact(&self, window: usize) -> FragmentPageArtifact { + let (margin_left, margin_top) = self.origin; + FragmentPageArtifact::build( + window, + &self.window_page_root(window), + &self.built, + margin_left, + margin_top, + ) + } +} diff --git a/crates/rito-core/src/runtime/spread.rs b/crates/rito-core/src/runtime/spread.rs index b067a7511..2c0c0b12a 100644 --- a/crates/rito-core/src/runtime/spread.rs +++ b/crates/rito-core/src/runtime/spread.rs @@ -14,7 +14,7 @@ pub(crate) struct SpreadSlot { /// are the first spreads of all of them. pub(crate) fn spread_last_page(layout_config: &LayoutConfig, index: usize) -> usize { match (layout_config.spread_mode, layout_config.first_page_alone) { - (SpreadMode::Single, _) => index, + (SpreadMode::Single | SpreadMode::Scrolled, _) => index, (_, true) => index.saturating_mul(2), (_, false) => index.saturating_mul(2).saturating_add(1), } @@ -28,7 +28,7 @@ pub(crate) fn build_spread_slots( if page_count == 0 { return Vec::new(); } - if layout_config.spread_mode == SpreadMode::Single { + if layout_config.spread_mode != SpreadMode::Double { return (0..page_count) .map(|index| SpreadSlot { index, @@ -93,6 +93,23 @@ mod tests { assert_eq!(slots[2].left_page_index, 2); } + #[test] + fn scrolled_mode_shows_each_window_alone() { + let chapter_start_pages = BTreeSet::from([2]); + + let slots = build_spread_slots( + 4, + &chapter_start_pages, + &layout(SpreadMode::Scrolled, false), + ); + + assert_eq!(slots.len(), 4); + assert!(slots + .iter() + .enumerate() + .all(|(index, slot)| slot.left_page_index == index && slot.right_page_index.is_none())); + } + #[test] fn double_spread_mode_keeps_first_page_alone() { let slots = build_spread_slots(4, &BTreeSet::new(), &layout(SpreadMode::Double, true)); diff --git a/crates/rito-core/src/runtime/tests.rs b/crates/rito-core/src/runtime/tests.rs index b4ef49617..63803c04e 100644 --- a/crates/rito-core/src/runtime/tests.rs +++ b/crates/rito-core/src/runtime/tests.rs @@ -1,7 +1,10 @@ mod access_tests; +mod annotation_space_tests; mod chapter_local_tests; +mod emphasis_tests; pub(crate) mod fixture; mod fragment_page_table_tests; +mod inline_block_tests; pub(crate) mod pinned_font_policy_fixtures; mod pinned_font_policy_tests; mod pinned_font_policy_validation_tests; @@ -9,10 +12,17 @@ mod pinned_font_wiring_tests; mod reading_anchor_tests; mod relayout_equivalence_tests; pub(crate) mod revisions; +mod ruby_overhang_tests; mod ruby_range_tests; +mod scroll_strip_tests; +mod scrolled_revision_tests; +mod strip_cost_probe; +mod stylesheet_scope_tests; mod text_granularity_tests; mod text_interaction_tests; mod text_movement_tests; +mod vertical_geometry_tests; +mod vertical_scroll_tests; mod word_selection_tests; use fixture::{ diff --git a/crates/rito-core/src/runtime/tests/annotation_space_tests.rs b/crates/rito-core/src/runtime/tests/annotation_space_tests.rs new file mode 100644 index 000000000..dbbbad3c3 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/annotation_space_tests.rs @@ -0,0 +1,306 @@ +//! A paragraph's first-line ruby reaches into the space above the line +//! before the line grows (Blink's block-start annotation space): the +//! collapsed margin above it and what the paragraph before it leaves under +//! its last line, unless a top border or the paragraph's own formatting +//! context closes it off. A line that opens a page has none of it. + +use rito_block::BlockFormattingContext; +use rito_fragment::{BreakToken, CancelFlag, Fragment, LineText}; +use rito_inline::ParleyInlineContext; + +use super::pinned_font_policy_fixtures::{bundled_font, content_epub, layout}; +use crate::fragment_pagination::{layout_chapter_page, layout_chapter_strip}; +use crate::runtime::RuntimeDocument; + +const RUBY: &str = "人族Emnetwiht一同灭亡了。"; +const PLAIN: &str = "人族一同灭亡了。"; +const PREVIOUS: &str = "这是前一段的文字。"; +const BASE: &str = "p { font-size: 18px; line-height: 120%; text-indent: 0; }"; +const WIDTH: f64 = 372.0; + +struct Book { + document: RuntimeDocument, + revision_id: String, + engine: BlockFormattingContext, +} + +fn book(body: &str, stylesheet: &str) -> Book { + let bytes = content_epub("zh-CN", body, &format!("{BASE}\n{stylesheet}"), None); + let mut document = RuntimeDocument::open_pinned_for_tests(&bytes).expect("document opens"); + let revision_id = document + .create_revision(&layout()) + .expect("revision") + .revision_id; + let inline = ParleyInlineContext::new(vec![bundled_font("SourceHanSerifCN-Regular.otf")]) + .expect("font registers"); + Book { + document, + revision_id, + engine: BlockFormattingContext::new(inline), + } +} + +/// Every line's baseline, ruby growth and ruby overflow, down the page. +fn lines(fragment: &Fragment) -> Vec<(f64, f64, f64)> { + fn walk(fragment: &Fragment, top: f64, out: &mut Vec<(f64, f64, f64)>) { + match fragment { + Fragment::Box(block) => { + for child in &block.children { + walk(child, top + block.rect.y, out); + } + } + Fragment::Line(line) => out.push(( + top + line.rect.y + line.baseline, + line.annotation_growth, + line.annotation_overflow, + )), + Fragment::Text(_) | Fragment::Image(_) => {} + } + } + let mut out = Vec::new(); + walk(fragment, 0.0, &mut out); + out +} + +fn strip_lines(body: &str, stylesheet: &str) -> Vec<(f64, f64, f64)> { + let book = book(body, stylesheet); + let built = book + .document + .chapter_formatting_tree(&book.revision_id, "chapter") + .expect("tree builds"); + let strip = layout_chapter_strip( + &book.engine, + &built.tree, + WIDTH, + 592.0, + LineText::Omitted, + &CancelFlag::new(), + ) + .expect("strip lays out"); + lines(&strip) +} + +/// How far the ruby pushes line `index` down: its baseline with the +/// annotation against the same body without it. +fn push(ruby_body: &str, stylesheet: &str, index: usize) -> f64 { + let plain_body = ruby_body.replace(RUBY, PLAIN); + let with = strip_lines(ruby_body, stylesheet)[index].0; + let without = strip_lines(&plain_body, stylesheet)[index].0; + with - without +} + +/// The overflow of a first-line ruby with no space above it. +fn overflow() -> f64 { + let pushed = push(&format!("

{RUBY}

"), "p { margin: 0; }", 0); + assert!(pushed > 5.0, "the annotation overflows the line: {pushed}"); + pushed +} + +#[test] +fn a_first_line_ruby_reaches_into_the_collapsed_margin_above() { + // 0.25em at 18px: the margin lends 4.5px. + let pushed = push(&format!("

{RUBY}

"), "p { margin: 0.25em 0; }", 0); + assert!( + (pushed - (overflow() - 4.5)).abs() < 1e-6, + "{pushed} vs {}", + overflow() - 4.5 + ); +} + +#[test] +fn a_top_border_keeps_the_ruby_out_of_the_margin() { + let pushed = push( + &format!("

{RUBY}

"), + "p { margin: 0.5em 0; } p.t { border-top: 1px solid black; }", + 0, + ); + assert!( + (pushed - overflow()).abs() < 1e-6, + "{pushed} vs {}", + overflow() + ); +} + +#[test] +fn a_paragraph_of_its_own_formatting_context_keeps_the_ruby_inside() { + let pushed = push( + &format!("

{RUBY}

"), + "p { margin: 0.5em 0; } p.t { display: flow-root; }", + 0, + ); + assert!( + (pushed - overflow()).abs() < 1e-6, + "{pushed} vs {}", + overflow() + ); +} + +#[test] +fn a_first_line_ruby_reuses_the_leading_under_the_paragraph_before() { + // With no margin between, the previous paragraph's last line lends + // what a previous line of the same paragraph would. + let across = push( + &format!("

{PREVIOUS}

{RUBY}

"), + "p { margin: 0; }", + 1, + ); + let within = push( + &format!("

{PREVIOUS}
{RUBY}

"), + "p { margin: 0; }", + 1, + ); + assert!( + within < overflow(), + "the previous line lends space: {within}" + ); + assert!((across - within).abs() < 1e-6, "{across} vs {within}"); +} + +/// The lines of each page of the chapter laid out `height` tall. +fn page_lines(body: &str, stylesheet: &str, height: f64) -> Vec> { + let book = book(body, stylesheet); + let built = book + .document + .chapter_formatting_tree(&book.revision_id, "chapter") + .expect("tree builds"); + let cancel = CancelFlag::new(); + let mut pages = Vec::new(); + let mut token: Option = None; + loop { + let (page, next) = layout_chapter_page( + &book.engine, + &built.tree, + WIDTH, + height, + token.as_ref(), + &cancel, + ) + .expect("page lays out"); + pages.push(lines(&page)); + match next { + Some(next) if pages.len() < 8 => token = Some(next), + _ => break, + } + } + pages +} + +#[test] +fn a_ruby_line_opening_a_resumed_page_takes_its_whole_overflow() { + // Two 21.6px lines fill the first page; the ruby line opens the next, + // where the line before it lends nothing. + let body = format!("

{PREVIOUS}
{PREVIOUS}
{RUBY}

"); + let pages = page_lines(&body, "p { margin: 0; }", 50.0); + let (_, growth, overflow) = pages[1][0]; + assert!(overflow > 5.0, "the ruby line opens page 2: {pages:?}"); + assert_eq!(growth, overflow, "{pages:?}"); +} + +#[test] +fn a_paragraph_opening_a_page_after_a_break_loses_the_margin_above() { + // The margin meeting the break is truncated, so it lends nothing. + let body = format!("

{PREVIOUS}

{RUBY}

"); + let pages = page_lines(&body, "p { margin: 0.5em 0; }", 40.0); + let (_, growth, overflow) = pages[1][0]; + assert!(overflow > 5.0, "the ruby paragraph opens page 2: {pages:?}"); + assert_eq!(growth, overflow, "{pages:?}"); +} + +#[test] +fn an_empty_line_above_lends_only_its_half_leading() { + // (21.6 - 18) / 2 on the LayoutUnit grid, as Chromium reuses it after + // `


`. + let half_leading = 1.796875; + let across = push(&format!("


{RUBY}

"), "p { margin: 0; }", 1); + assert!( + (across - (overflow() - half_leading)).abs() < 1e-6, + "{across} vs {}", + overflow() - half_leading + ); + let within = push( + &format!("

{PREVIOUS}

{RUBY}

"), + "p { margin: 0; }", + 2, + ); + assert!((within - across).abs() < 1e-6, "{within} vs {across}"); +} + +#[test] +fn a_ruby_paragraph_lent_space_above_resumes_where_its_first_page_stopped() { + // The first page lays the paragraph out against the paragraph before + // it, the next resumes it without; both must slice one geometry. + let long = PREVIOUS.repeat(24); + let body = format!("

{PREVIOUS}

{RUBY}{long}

"); + let continuous = strip_lines(&body, "p { margin: 0; }").len(); + let pages = page_lines(&body, "p { margin: 0; }", 120.0); + let placed: usize = pages.iter().map(Vec::len).sum(); + assert!(pages.len() > 1, "the paragraph splits: {pages:?}"); + assert_eq!(placed, continuous, "{pages:?}"); +} + +#[test] +#[ignore = "drill-down: RITO_DRILL_EPUB= RITO_DRILL_CHAPTER= RITO_DRILL_PAGES=a-b"] +fn drill_pages() { + let path = std::env::var("RITO_DRILL_EPUB").expect("book"); + let idref = std::env::var("RITO_DRILL_CHAPTER").expect("chapter"); + let pages = std::env::var("RITO_DRILL_PAGES").unwrap_or_else(|_| "0-2".to_owned()); + let (from, to) = pages.split_once('-').expect("a-b"); + let (from, to): (usize, usize) = (from.parse().unwrap(), to.parse().unwrap()); + let bytes = std::fs::read(path).expect("book reads"); + let mut document = RuntimeDocument::open_pinned_for_tests(&bytes).expect("document opens"); + let revision_id = document + .create_revision(&layout()) + .expect("revision") + .revision_id; + let inline = ParleyInlineContext::new(vec![bundled_font("SourceHanSerifCN-Regular.otf")]) + .expect("font registers"); + let engine = BlockFormattingContext::new(inline); + let built = document + .chapter_formatting_tree(&revision_id, &idref) + .expect("tree builds"); + let cancel = CancelFlag::new(); + let mut token: Option = None; + for page in 0..=to { + let (fragment, next) = + layout_chapter_page(&engine, &built.tree, 640.0, 850.0, token.as_ref(), &cancel) + .expect("page lays out"); + if page >= from { + fn walk(f: &Fragment, top: f64, depth: usize) { + match f { + Fragment::Box(b) => { + eprintln!( + "{}box {:?} y={} h={}", + " ".repeat(depth), + b.source, + top + b.rect.y, + b.rect.height + ); + for c in &b.children { + walk(c, top + b.rect.y, depth + 1); + } + } + Fragment::Line(l) => eprintln!( + "{}line y={} h={} base={} growth={} overflow={} runs={:?}", + " ".repeat(depth), + top + l.rect.y, + l.rect.height, + l.baseline, + l.annotation_growth, + l.annotation_overflow, + l.text_runs.first() + ), + _ => {} + } + } + eprintln!( + "=== page {page} token {:?}", + token.as_ref().map(|t| (&t.resume_path, &t.stage)) + ); + walk(&fragment, 0.0, 0); + } + match next { + Some(next) => token = Some(next), + None => break, + } + } +} diff --git a/crates/rito-core/src/runtime/tests/emphasis_tests.rs b/crates/rito-core/src/runtime/tests/emphasis_tests.rs new file mode 100644 index 000000000..55bd913f2 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/emphasis_tests.rs @@ -0,0 +1,199 @@ +//! Where emphasis marks put a paragraph's lines, as Chromium 156 lays +//! them out: the pinned serif at 16px on the fixture page (a 372 × 592 +//! content box), the chapter's body without margins. A mark half the +//! text's size stands outside the em box of the text it marks; marks over +//! a line push it down by what the room under the previous line's text +//! does not absorb, marks under a line grow its box, and the next line +//! rises into that growth by as much as the room above its own text. The +//! expected boxes are the paragraphs' `getBoundingClientRect()` in a +//! 372 × 592 viewport. + +use super::fixture::layout; +use super::pinned_font_policy_fixtures::{bundled_font, content_epub, face, policy}; +use crate::render::DisplayCommand; +use crate::runtime::{RuntimeDocument, RuntimePinnedFontGenericRole}; + +/// The page's content-box origin on the fixture page. +const ORIGIN: f64 = 24.0; + +/// What the first page paints: each paragraph's border box and the +/// centre of each emphasis mark's outline. +fn painted(body: &str, css: &str) -> (Vec<[f64; 4]>, Vec<[f64; 2]>) { + let bytes = content_epub( + "zh-CN", + body, + &format!("body {{ margin: 0; font-size: 16px; }} p {{ background: #eee; }} {css}"), + None, + ); + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &bytes, + policy(vec![face( + bundled_font("SourceHanSerifCN-Regular.otf"), + RuntimePinnedFontGenericRole::Serif, + None, + )]), + ) + .expect("document opens"); + let summary = document.create_revision(&layout()).expect("revision"); + let page = document.revisions[&summary.revision_id] + .fragment_layout + .page(0) + .expect("page lays out"); + let mut boxes = Vec::new(); + let mut marks = Vec::new(); + for command in page.commands_for(1.0).expect("page paints").iter() { + match command { + DisplayCommand::PaintBlock { rect, .. } => { + boxes.push([rect.x - ORIGIN, rect.y - ORIGIN, rect.width, rect.height]); + } + // The pinned serif's filled circle at 8px: its outline centres + // 4 right of its origin and 3.04 above it. + DisplayCommand::PaintRuby(marks_run) => marks.extend( + marks_run + .clusters + .iter() + .map(|mark| [mark.x - ORIGIN + 4.0, mark.y - ORIGIN - 3.04]), + ), + _ => {} + } + } + (boxes, marks) +} + +fn paragraph_boxes(body: &str, css: &str) -> Vec<[f64; 4]> { + painted(body, css).0 +} + +struct Case { + name: &'static str, + css: &'static str, + body: &'static str, + boxes: &'static [[f64; 4]], +} + +const CASES: &[Case] = &[ + // Dots under a 20.796875px line reach 26 below its top (its em box + // ends at 18, the mark is 8 tall): the box grows by the rest, and + // the margins around it stay whole. + Case { + name: "marks-under-one-line", + css: "p { line-height: 1.3; margin: 6.4px 0; } \ + .e { text-emphasis: circle; text-emphasis-position: under; }", + body: "

这是前一段的文字。

甲乙丙丁戊己。

\ +

这是后一段的文字。

", + boxes: &[ + [0.0, 6.390625, 372.0, 20.796875], + [0.0, 33.578125, 372.0, 26.0], + [0.0, 65.96875, 372.0, 20.796875], + ], + }, + // The second line rises one pixel into the first's growth: the room + // above its own text. + Case { + name: "marks-under-two-lines", + css: "p { line-height: 1.3; margin: 0; } \ + .e { text-emphasis: circle; text-emphasis-position: under; }", + body: "

一二三四五六七八九十一二三四五六七八九十一二三四五\ + 六七八九十一二三四五六七八九十

后一段。

", + boxes: &[[0.0, 0.0, 372.0, 51.0], [0.0, 51.0, 372.0, 20.796875]], + }, + // The marks spend the bottom padding before the box grows. + Case { + name: "marks-under-into-padding", + css: "p { line-height: 1.3; margin: 0; } .pad { padding-bottom: 3px; } \ + .e { text-emphasis: circle; text-emphasis-position: under; }", + body: "

甲乙丙

后一段。

", + boxes: &[[0.0, 0.0, 372.0, 26.0], [0.0, 26.0, 372.0, 20.796875]], + }, + // A 16px line leaves no room under its text: dots over the next line + // push it down their whole 9px past its top, a first line and an + // interior one alike. + Case { + name: "marks-over-tight-lines", + css: "p { line-height: 1; margin: 0; } .e { text-emphasis: filled dot; }", + body: "

前一段。

甲乙丙丁

\ +

一二三四五六七八九十一二三四五六七八九十一二三四五\ + 六七八九十

", + boxes: &[ + [0.0, 0.0, 372.0, 16.0], + [0.0, 16.0, 372.0, 25.0], + [0.0, 41.0, 372.0, 41.0], + ], + }, + // A 28px column leaves room enough on its left for sesames over the + // next column's text. + Case { + name: "sesames-over-roomy-columns", + css: "html { writing-mode: vertical-rl; } p { line-height: 1.75; margin: 0; } \ + .e { text-emphasis: filled sesame; }", + body: "

前一段。

甲乙丙丁

", + boxes: &[[344.0, 0.0, 28.0, 592.0], [316.0, 0.0, 28.0, 592.0]], + }, + // A 19.1875px column leaves 1.1875 of room left of its text; the + // sesames reach 7 right of the next column, which moves left by the + // rest. + Case { + name: "sesames-over-tight-columns", + css: "html { writing-mode: vertical-rl; } p { line-height: 1.2; margin: 0; } \ + .e { text-emphasis: filled sesame; }", + body: "

前一段。

甲乙丙丁

\ +

一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十\ + 一二三四五六七八九十六七八九十

", + boxes: &[ + [352.8125, 0.0, 19.1875, 592.0], + [327.8125, 0.0, 25.0, 592.0], + [283.625, 0.0, 44.1875, 592.0], + ], + }, +]; + +#[test] +fn emphasis_marks_place_lines_as_chromium_does() { + for case in CASES { + let got = paragraph_boxes(case.body, case.css); + let close = got.len() == case.boxes.len() + && got + .iter() + .zip(case.boxes) + .all(|(got, expected)| got.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6)); + assert!(close, "{}: {got:?}, Chromium {:?}", case.name, case.boxes); + } +} + +/// Filled circles (whose ink centroid is their outline's centre) after a +/// 28px line: a horizontal line's under its text, a vertical line's on +/// either side of its column. The expected centres are the centroids of +/// Chromium's mark ink (its screenshot with the marks less without). +#[test] +fn emphasis_marks_paint_where_chromium_paints_them() { + let body = "

前一段。

甲乙丙丁

"; + let cases: [(&str, &str, [[f64; 2]; 2]); 3] = [ + ( + "under-a-horizontal-line", + "p { line-height: 1.75; margin: 0; } \ + .e { text-emphasis: circle; text-emphasis-position: under; }", + [[24.0, 54.92], [40.0, 54.92]], + ), + ( + "right-of-a-column", + "html { writing-mode: vertical-rl; } p { line-height: 1.75; margin: 0; } \ + .e { text-emphasis: filled circle; }", + [[343.5, 23.91], [343.5, 39.91]], + ), + ( + "left-of-a-column", + "html { writing-mode: vertical-rl; } p { line-height: 1.75; margin: 0; } \ + .e { text-emphasis: filled circle; text-emphasis-position: left over; }", + [[317.5, 23.91], [317.5, 39.91]], + ), + ]; + for (name, css, expected) in cases { + let (_, marks) = painted(body, css); + // Anti-aliasing pulls an ink centroid a tenth of a pixel. + let close = marks.len() == expected.len() + && marks.iter().zip(&expected).all(|(got, want)| { + (got[0] - want[0]).abs() < 0.15 && (got[1] - want[1]).abs() < 0.15 + }); + assert!(close, "{name}: {marks:?}, Chromium {expected:?}"); + } +} diff --git a/crates/rito-core/src/runtime/tests/fixture.rs b/crates/rito-core/src/runtime/tests/fixture.rs index 34bcb7139..26a375a84 100644 --- a/crates/rito-core/src/runtime/tests/fixture.rs +++ b/crates/rito-core/src/runtime/tests/fixture.rs @@ -527,6 +527,71 @@ fn many_chapter_fixture_epub_with( writer.finish().expect("zip finalizes").into_inner() } +/// A book whose pages progress right to left: a horizontal plate page, +/// then a vertical-rl chapter of columns long enough to take several +/// windows of a narrow viewport. +pub fn vertical_book_fixture_epub() -> Vec { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let options: FileOptions<'_, ()> = FileOptions::default(); + add_file( + &mut writer, + options, + "META-INF/container.xml", + br#" + + + + +"#, + ); + add_file( + &mut writer, + options, + "OPS/package.opf", + br#" + + + Vertical book + zh + vertical-book + + + + + + + + + +"#, + ); + add_file( + &mut writer, + options, + "OPS/plate.xhtml", + "

横排的插图说明。

" + .as_bytes(), + ); + let paragraphs = (0..12) + .map(|index| { + format!( + "

{}

", + "春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。".repeat(6) + ) + }) + .collect::(); + let columns = format!( + "{paragraphs}" + ); + add_file( + &mut writer, + options, + "OPS/columns.xhtml", + columns.as_bytes(), + ); + writer.finish().expect("zip finalizes").into_inner() +} + pub fn minimal_png() -> Vec { let mut bytes = vec![0u8; 33]; bytes[0..8].copy_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); diff --git a/crates/rito-core/src/runtime/tests/fragment_page_table_tests.rs b/crates/rito-core/src/runtime/tests/fragment_page_table_tests.rs index 6031862f2..c8ef7f929 100644 --- a/crates/rito-core/src/runtime/tests/fragment_page_table_tests.rs +++ b/crates/rito-core/src/runtime/tests/fragment_page_table_tests.rs @@ -472,7 +472,9 @@ fn fragment_pages_resolve_keyboard_selection_movement() { lines.selected_text ); - // Word right from the original selection lands at the next word edge. + // Word right from the original selection skips the space to the end + // of the next word, as Chromium's word movement does; word left from + // the grown selection goes back over it to the start of "quick". let RuntimeTextSelectionMovementResolution::Resolved { range: word, .. } = movement( anchor_caret.address, focus_caret.address, @@ -480,12 +482,15 @@ fn fragment_pages_resolve_keyboard_selection_movement() { ) else { panic!("word movement resolves on a fragment page"); }; - assert!( - word.selected_text.starts_with("quick"), - "word-right keeps the anchor, got {:?}", - word.selected_text - ); - assert!(word.selected_text.len() > "quick".len()); + assert_eq!(word.selected_text, "quick brown"); + let RuntimeTextSelectionMovementResolution::Resolved { range: back, .. } = movement( + after_char.address, + after_char.address, + TextSelectionMovement::WordLeft, + ) else { + panic!("word movement resolves on a fragment page"); + }; + assert_eq!(back.selected_text, "quick "); } #[test] @@ -682,7 +687,7 @@ fn search_finds_text_after_the_fragment_page_table_attaches() { fn painted_image_rects(css: &str) -> Vec<(f64, f64)> { let epub = fixture_epub_with_chapter_and_stylesheet( - br#"t

plate

"#, + br#"t

plate

"#, css, ); let mut document = RuntimeDocument::open_with_pinned_font_policy( @@ -726,12 +731,13 @@ fn painted_image_rects(css: &str) -> Vec<(f64, f64)> { fn the_ua_stylesheet_letterboxes_an_author_box_off_the_raster_ratio() { // The fixture raster is 2x3 (portrait); the author forces a 30x20 // landscape box. The UA `img { object-fit: contain }` letterboxes - // the raster inside it at its own ratio. + // the raster inside it at its own ratio, 13.33x20, and the placed + // rect snaps to whole pixels. let rects = painted_image_rects("img { width: 30px; height: 20px; }\n"); assert_eq!(rects.len(), 1); let (width, height) = rects[0]; assert!( - (height - 20.0).abs() < 0.6 && (width / height - 2.0 / 3.0).abs() < 0.01, + (height - 20.0).abs() < 0.6 && (width - 20.0 * 2.0 / 3.0).abs() <= 1.0, "the raster keeps its 2:3 ratio inside the 30x20 box, got {width}x{height}" ); } diff --git a/crates/rito-core/src/runtime/tests/inline_block_tests.rs b/crates/rito-core/src/runtime/tests/inline_block_tests.rs new file mode 100644 index 000000000..749fb4849 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/inline_block_tests.rs @@ -0,0 +1,147 @@ +//! An inline block stands on its line by its margin box: its margins, +//! borders and padding surround its content, push its last line's +//! baseline down and grow the line it rides, as Chromium 156 lays out a +//! translation wrapped in `display: inline-block; margin: 8px 0` (the +//! pinned serif at 18px, `line-height: 120%`, 372px wide; the offsets are +//! from the first line's baseline, read off the DOM line rects). + +use rito_block::BlockFormattingContext; +use rito_fragment::{CancelFlag, Fragment, LineText}; +use rito_inline::ParleyInlineContext; + +use super::pinned_font_policy_fixtures::{bundled_font, content_epub, layout}; +use crate::fragment_pagination::layout_chapter_strip; +use crate::runtime::RuntimeDocument; + +const BODY: &str = "

Harry and Ron met.
隔天早餐前,哈利和榮恩在公共休息室見到了妙麗。哈利希望自己的理論能得到一些支持,他立刻告訴妙麗。

Next paragraph.

"; + +/// Each line's baseline down the chapter. +fn baselines(stylesheet: &str) -> Vec { + let bytes = content_epub( + "zh-CN", + BODY, + &format!("p {{ margin: 0; font-size: 18px; line-height: 120%; }} {stylesheet}"), + None, + ); + let mut document = RuntimeDocument::open_pinned_for_tests(&bytes).expect("document opens"); + let revision_id = document + .create_revision(&layout()) + .expect("revision") + .revision_id; + let inline = ParleyInlineContext::new(vec![bundled_font("SourceHanSerifCN-Regular.otf")]) + .expect("font registers"); + let engine = BlockFormattingContext::new(inline); + let built = document + .chapter_formatting_tree(&revision_id, "chapter") + .expect("tree builds"); + let strip = layout_chapter_strip( + &engine, + &built.tree, + 372.0, + 592.0, + LineText::Omitted, + &CancelFlag::new(), + ) + .expect("strip lays out"); + fn walk(fragment: &Fragment, top: f64, out: &mut Vec) { + match fragment { + Fragment::Box(block) => { + for child in &block.children { + walk(child, top + block.rect.y, out); + } + } + Fragment::Line(line) => out.push(top + line.rect.y + line.baseline), + Fragment::Text(_) | Fragment::Image(_) => {} + } + } + let mut out = Vec::new(); + walk(&strip, 0.0, &mut out); + out +} + +#[test] +fn an_inline_block_rides_its_line_by_its_margin_box() { + for (stylesheet, block_line, next_paragraph) in [ + ( + ".w { display: inline-block; margin: 8px 0; }", + 94.375, + 123.96875, + ), + ( + ".w { display: inline-block; margin: 8px 0; padding: 3px 0; }", + 97.375, + 129.96875, + ), + ] { + let lines = baselines(stylesheet); + let first = lines[0]; + assert_eq!( + [lines[1] - first, lines[2] - first], + [block_line, next_paragraph], + "{stylesheet}: {lines:?}" + ); + } +} + +/// The lines each page holds, nested boxes included. +fn page_line_counts(body: &str, stylesheet: &str, height: f64) -> Vec { + use crate::fragment_pagination::layout_chapter_page; + let bytes = content_epub("en", body, stylesheet, None); + let mut document = RuntimeDocument::open_pinned_for_tests(&bytes).expect("document opens"); + let revision_id = document + .create_revision(&layout()) + .expect("revision") + .revision_id; + let inline = ParleyInlineContext::new(vec![bundled_font("SourceHanSerifCN-Regular.otf")]) + .expect("font registers"); + let engine = BlockFormattingContext::new(inline); + let built = document + .chapter_formatting_tree(&revision_id, "chapter") + .expect("tree builds"); + fn count(fragment: &Fragment) -> usize { + match fragment { + Fragment::Box(block) => block.children.iter().map(count).sum(), + Fragment::Line(_) => 1, + Fragment::Text(_) | Fragment::Image(_) => 0, + } + } + let cancel = CancelFlag::new(); + let mut counts = Vec::new(); + let mut token = None; + loop { + let (page, next) = + layout_chapter_page(&engine, &built.tree, 372.0, height, token.as_ref(), &cancel) + .expect("page lays out"); + counts.push(count(&page)); + match next { + Some(next) if counts.len() < 6 => token = Some(next), + _ => break, + } + } + counts +} + +/// An inline table is an atomic inline, which a line never breaks +/// inside: when it does not fit what is left of a page it moves to the +/// next one whole, where a block table breaks between its rows (as +/// Chromium fragments a book's `table { display: inline-table }` results +/// grid, leaving the page bare under the heading above it). +#[test] +fn an_inline_table_moves_to_the_next_page_whole() { + let body = "

a

b

c

1
2
3
4
5
6
"; + let base = "p { margin: 0; line-height: 20px; } table { border-spacing: 0; } td { padding: 0; line-height: 20px; }"; + assert_eq!( + page_line_counts(body, base, 100.0), + vec![5, 4], + "a block table breaks between rows" + ); + assert_eq!( + page_line_counts( + body, + &format!("{base} table {{ display: inline-table; }}"), + 100.0 + ), + vec![3, 6], + "an inline table moves whole" + ); +} diff --git a/crates/rito-core/src/runtime/tests/pinned_font_policy_fixtures.rs b/crates/rito-core/src/runtime/tests/pinned_font_policy_fixtures.rs index 5f59c1ea3..774f5dc88 100644 --- a/crates/rito-core/src/runtime/tests/pinned_font_policy_fixtures.rs +++ b/crates/rito-core/src/runtime/tests/pinned_font_policy_fixtures.rs @@ -16,11 +16,11 @@ use crate::runtime::{ use super::fixture::add_file; pub(super) use super::fixture::layout; -pub(super) fn policy(faces: Vec) -> RuntimePinnedFontPolicyInput { +pub(crate) fn policy(faces: Vec) -> RuntimePinnedFontPolicyInput { RuntimePinnedFontPolicyInput { faces } } -pub(super) fn face( +pub(crate) fn face( bytes: Vec, generic_role: RuntimePinnedFontGenericRole, language: Option<&str>, @@ -47,7 +47,7 @@ pub(super) fn serif_text_font() -> Vec { } /// A bundled reader font by file name (`apps/reader/src/assets/fonts`). -pub(super) fn bundled_font(file: &str) -> Vec { +pub(crate) fn bundled_font(file: &str) -> Vec { std::fs::read(workspace_path(&format!( "apps/reader/src/assets/fonts/{file}" ))) @@ -77,6 +77,18 @@ pub(crate) fn content_epub( body: &str, stylesheet: &str, publication_font: Option<&[u8]>, +) -> Vec { + content_epub_linking(language, body, stylesheet, publication_font, true) +} + +/// [`content_epub`] whose chapter links the stylesheet only when `linked` +/// (the package lists it either way). +pub(crate) fn content_epub_linking( + language: &str, + body: &str, + stylesheet: &str, + publication_font: Option<&[u8]>, + linked: bool, ) -> Vec { let mut writer = ZipWriter::new(Cursor::new(Vec::new())); let options: FileOptions<'_, ()> = FileOptions::default(); @@ -92,8 +104,13 @@ pub(crate) fn content_epub( let package = format!( r#"Pinned{language}pinned{font_manifest}"# ); + let link = if linked { + r#""# + } else { + "" + }; let chapter = format!( - r#"{body}"# + r#"{link}{body}"# ); add_file(&mut writer, options, "OPS/package.opf", package.as_bytes()); add_file(&mut writer, options, "OPS/style.css", stylesheet.as_bytes()); diff --git a/crates/rito-core/src/runtime/tests/ruby_overhang_tests.rs b/crates/rito-core/src/runtime/tests/ruby_overhang_tests.rs new file mode 100644 index 000000000..880387b30 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/ruby_overhang_tests.rs @@ -0,0 +1,154 @@ +//! A wide ruby annotation overhangs the text beside it on its own line +//! only, and a line the breaker ends by rewinding to an item boundary aligns +//! as if each ruby on it were its whole column wide, as Chromium lays them +//! out. Positions are Chromium's (the pinned serif at 18px in a 300px +//! column; the annotation "Fragment" is 68px over a 36px base and +//! overhangs 7px each side mid-line). + +use rito_block::BlockFormattingContext; +use rito_fragment::{CancelFlag, Fragment, LineText}; +use rito_inline::ParleyInlineContext; + +use super::pinned_font_policy_fixtures::{bundled_font, content_epub, layout}; +use crate::fragment_pagination::layout_chapter_strip; +use crate::runtime::RuntimeDocument; + +const RUBY: &str = "碎片Fragment"; +const STYLE: &str = "p { width: 300px; margin: 0; text-indent: 0; font-size: 18px; \ + line-height: 1.5; } rt { font-size: 0.8em; }"; + +/// Each glyph's (line, left) by its character index in the paragraph's +/// base text, with `align` as the paragraph's text-align. +fn glyphs(body: &str, align: &str) -> Vec<(usize, f64)> { + let bytes = content_epub( + "zh-CN", + &format!("

{body}

"), + &format!("{STYLE} p {{ text-align: {align}; }}"), + None, + ); + let mut document = RuntimeDocument::open_pinned_for_tests(&bytes).expect("document opens"); + let revision_id = document + .create_revision(&layout()) + .expect("revision") + .revision_id; + let inline = ParleyInlineContext::new(vec![bundled_font("SourceHanSerifCN-Regular.otf")]) + .expect("font registers"); + let engine = BlockFormattingContext::new(inline); + let built = document + .chapter_formatting_tree(&revision_id, "chapter") + .expect("tree builds"); + let strip = layout_chapter_strip( + &engine, + &built.tree, + 372.0, + 592.0, + LineText::All, + &CancelFlag::new(), + ) + .expect("strip lays out"); + let mut by_byte = Vec::new(); + collect(&strip, 0.0, &mut 0, &mut by_byte); + by_byte.sort_by_key(|(byte, _, _)| *byte); + by_byte.into_iter().map(|(_, line, x)| (line, x)).collect() +} + +fn collect(fragment: &Fragment, left: f64, line: &mut usize, out: &mut Vec<(u32, usize, f64)>) { + match fragment { + Fragment::Box(block) => { + for child in &block.children { + collect(child, left + block.rect.x, line, out); + } + } + Fragment::Line(row) => { + for child in &row.children { + if let Fragment::Text(run) = child { + for cluster in run.clusters.iter() { + out.push(( + cluster.byte, + *line, + left + row.rect.x + run.rect.x + cluster.x, + )); + } + } + } + *line += 1; + } + Fragment::Text(_) | Fragment::Image(_) => {} + } +} + +/// Within one LayoutUnit: the annotation's advance sums glyph advances +/// that Chromium takes from HarfBuzz in 16.16 fixed point, so its column +/// can end one LayoutUnit apart from the engine's (both glyph positions +/// raster in the same quarter-pixel bucket). +fn assert_at(glyphs: &[(usize, f64)], index: usize, line: usize, left: f64) { + let (got_line, got_left) = glyphs[index]; + assert!( + got_line == line && (got_left - left).abs() <= 1.0 / 64.0 + 1e-9, + "glyph {index}: line {got_line} at {got_left}, Chromium puts it on line {line} at {left}" + ); +} + +#[test] +fn a_ruby_opening_a_line_does_not_overhang_backwards() { + // The column starts the line; only its end overhangs the next glyph. + let glyphs = glyphs( + &format!("{}{RUBY}{}", "灵".repeat(16), "附".repeat(20)), + "left", + ); + assert_eq!( + glyphs[16].0, 1, + "the ruby opens the second line: {glyphs:?}" + ); + assert_at(&glyphs, 18, 1, 60.953125); +} + +#[test] +fn a_ruby_mid_line_overhangs_both_neighbours() { + let glyphs = glyphs( + &format!("{}{RUBY}{}", "灵".repeat(5), "附".repeat(20)), + "left", + ); + assert_at(&glyphs, 7, 0, 143.953125); +} + +#[test] +fn a_justified_line_closing_on_a_ruby_is_not_expanded() { + // The next glyph does not fit: the breaker rewinds to the ruby's end + // and counts the column whole, 302 against 300, so the line falls back + // to start alignment. + let glyphs = glyphs( + &format!("{}{RUBY}{}", "灵".repeat(13), "附".repeat(20)), + "justify", + ); + assert_at(&glyphs, 1, 0, 18.0); + assert_at(&glyphs, 15, 1, 0.0); +} + +#[test] +fn a_justified_line_ending_at_an_element_counts_its_rubies_whole() { + let glyphs = glyphs( + &format!( + "{}{RUBY}{}{}", + "灵".repeat(3), + "灵".repeat(10), + "附".repeat(20) + ), + "justify", + ); + assert_at(&glyphs, 5, 0, 107.953125); + assert_at(&glyphs, 14, 0, 269.953125); + assert_at(&glyphs, 15, 1, 0.0); +} + +#[test] +fn a_ruby_overhangs_neither_a_line_break_nor_the_paragraph_end() { + // Centered, the line is the base before it, the whole column less its + // start overhang: 54 + 68 - 7, centered in 300. + for after in ["
附附附", ""] { + let glyphs = glyphs(&format!("{}{RUBY}{after}", "灵".repeat(3)), "center"); + assert_at(&glyphs, 0, 0, 92.515625); + } + let glyphs = glyphs(&format!("{}{RUBY}附附附", "灵".repeat(3)), "center"); + assert_at(&glyphs, 0, 0, 69.015625); +} diff --git a/crates/rito-core/src/runtime/tests/scroll_strip_tests.rs b/crates/rito-core/src/runtime/tests/scroll_strip_tests.rs new file mode 100644 index 000000000..3a27cf086 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/scroll_strip_tests.rs @@ -0,0 +1,379 @@ +//! A chapter's strip read through its windows. + +use super::fixture::{ + long_paragraphs_fixture_epub, paint_command_kitchen_sink_fixture_epub, pinned_test_font_policy, + vertical_book_fixture_epub, +}; +use crate::fragment_window::ScrollAxis; +use crate::layout::{ + create_layout_config, LayoutConfig, LayoutConfigInput, MarginInput, SpreadMode, +}; +use crate::runtime::page_artifact::FragmentPageArtifact; +use crate::runtime::scroll_strip::ChapterStrip; +use crate::runtime::source_locator::PaginationTarget; +use crate::runtime::RuntimeDocument; + +/// A short window, so every chapter takes several and their boundaries +/// cross tables, images and ruby. +fn short_windows() -> LayoutConfig { + create_layout_config(LayoutConfigInput { + width: 420.0, + height: 240.0, + margin: MarginInput::All(24.0), + spread: SpreadMode::Single, + first_page_alone: false, + spread_gap: 0.0, + root_font_size: 16.0, + line_height_override: None, + line_height_force: None, + font_family_override: None, + font_family_force: None, + device_pixel_ratio: 1.0, + }) +} + +fn strip_of(bytes: &[u8]) -> ChapterStrip { + let mut document = + RuntimeDocument::open_with_pinned_font_policy(bytes, pinned_test_font_policy()) + .expect("fixture opens"); + let build = document + .build_chapter_local_fragment_layout(&short_windows(), 0, &PaginationTarget::Start) + .expect("chapter builds"); + build.layout.chapter_strip(0).expect("strip lays out") +} + +/// The text of each run an artifact records, in order. +fn run_texts(artifact: &FragmentPageArtifact) -> Vec { + let text: Vec = artifact.page_text().encode_utf16().collect(); + artifact + .interaction_runs() + .iter() + .map(|run| String::from_utf16_lossy(&text[run.start..run.end])) + .collect() +} + +fn text_fragments(fragment: &rito_fragment::Fragment) -> usize { + use rito_fragment::Fragment; + match fragment { + Fragment::Text(_) => 1, + Fragment::Box(inner) => inner.children.iter().map(text_fragments).sum(), + Fragment::Line(line) => line.children.iter().map(text_fragments).sum(), + Fragment::Image(_) => 0, + } +} + +#[test] +fn a_window_laid_out_with_its_own_text_reads_as_the_strip_laid_out_with_all_of_it() { + for bytes in [ + long_paragraphs_fixture_epub(), + paint_command_kitchen_sink_fixture_epub(), + ] { + let mut document = + RuntimeDocument::open_with_pinned_font_policy(&bytes, pinned_test_font_policy()) + .expect("fixture opens"); + let build = document + .build_chapter_local_fragment_layout(&short_windows(), 0, &PaginationTarget::Start) + .expect("chapter builds"); + let layout = &build.layout; + let engine = layout.strip_engine().expect("the table keeps its engine"); + let plain = layout + .chapter_strip_laid_out(0, false) + .expect("strip lays out without its text"); + let whole = layout.chapter_strip(0).expect("strip lays out with it"); + let mut least = usize::MAX; + for window in 0..plain.windows().count() { + let text = plain + .window_text(engine, window) + .expect("window text lays out"); + for ratio in [1.0, 2.625] { + assert_eq!( + plain + .window_frame(&text, window..window + 1, ratio) + .expect("window paints") + .0, + whole + .window_frame(whole.whole(), window..window + 1, ratio) + .expect("window paints") + .0, + "window {window} at {ratio}" + ); + } + assert_eq!( + plain.window_text_page_root(&text, window), + whole.window_page_root(window), + "window {window} records" + ); + least = least.min(text_fragments(&text)); + } + // A window reaches one window either side: the first of three or + // more leaves the last one's lines without their text. + assert!( + least < text_fragments(whole.whole()), + "a window holds the text of the lines near it, not the chapter's" + ); + } +} + +#[test] +fn a_chapters_windows_record_each_of_its_runs_once_in_order() { + for bytes in [ + long_paragraphs_fixture_epub(), + paint_command_kitchen_sink_fixture_epub(), + ] { + let strip = strip_of(&bytes); + let windows = strip.windows(); + assert!(windows.count() > 1, "the chapter takes several windows"); + let recorded: Vec = (0..windows.count()) + .flat_map(|window| run_texts(&strip.window_artifact(window))) + .collect(); + assert_eq!(recorded, run_texts(&strip.whole_artifact())); + } +} + +#[test] +fn a_window_records_its_runs_from_its_own_top() { + let strip = strip_of(&long_paragraphs_fixture_epub()); + let windows = strip.windows(); + for window in 0..windows.count() { + let artifact = strip.window_artifact(window); + let runs = artifact.interaction_runs(); + assert!(!runs.is_empty(), "window {window} records text"); + for run in runs { + assert!( + (0.0..windows.length()).contains(&run.y), + "window {window}: a run at {} starts outside the window", + run.y + ); + } + } +} + +/// A window draws its content exactly as the pair of it and the window +/// above draws that content: the same primitives, in the same order, in the +/// bytes a pen receives, at every ratio. Each moves onto its own origin by +/// whole device rows (`render::shift`), so neighbouring windows tile their +/// chapter. (The pens' rasters of a window and the pair may still differ by +/// antialiasing levels where a canvas picks its mode from the whole frame; +/// `tools/paint-parity/seams.mjs` reports those.) +#[test] +fn a_window_draws_its_content_as_the_pair_with_the_window_above_draws_it() { + use crate::render::{encode_reader_primitive_list, Primitive, PrimitiveList}; + + for bytes in [ + long_paragraphs_fixture_epub(), + paint_command_kitchen_sink_fixture_epub(), + ] { + let mut document = + RuntimeDocument::open_with_pinned_font_policy(&bytes, pinned_test_font_policy()) + .expect("fixture opens"); + let build = document + .build_chapter_local_fragment_layout(&short_windows(), 0, &PaginationTarget::Start) + .expect("chapter builds"); + let strip = build.layout.chapter_strip(0).expect("strip lays out"); + let windows = strip.windows(); + let images = |href: &str| document.image_size(href); + for ratio in [1.0, 1.25, 1.5, 2.0, 2.625, 3.0] { + let encoded = |primitive: &Primitive| { + encode_reader_primitive_list(&PrimitiveList { + ratio, + commands: vec![primitive.clone()], + }) + .expect("primitive encodes") + .bytes + }; + for first in 0..windows.count() - 1 { + let below = strip + .lowered_content(first + 1..first + 2, ratio, &images) + .expect("window lowers"); + let pair = strip + .lowered_content(first..first + 2, ratio, &images) + .expect("pair lowers"); + let pair: Vec> = pair.commands.iter().map(encoded).collect(); + let mut drawn = pair.iter(); + for primitive in &below.commands { + let bytes = encoded(primitive); + assert!( + drawn.any(|by_pair| *by_pair == bytes), + "ratio {ratio} window {}: {primitive:?} is not drawn by the pair", + first + 1 + ); + } + } + } + } +} + +/// The scroll-seam instrument's lowering step (`tools/paint-parity/seams.mjs`): +/// neighbouring windows of a synthetic and a real chapter lowered one by +/// one and as one run, at every ratio, for the pens to raster and the +/// instrument to stack. The runs' pinned faces are named as the pens +/// register them and every image draws the pens' synthetic bitmap, so the +/// rasters hold real glyphs and real image boxes. Skips itself unless +/// `RITO_SCROLL_SEAMS_OUT` names the output. +#[test] +fn lower_scroll_seam_fixtures() { + use std::{env, fs, path::Path, sync::Arc}; + + use serde_json::json; + use sha2::{Digest, Sha256}; + + use super::pinned_font_policy_fixtures::{face, policy}; + use crate::render::{encode_reader_primitive_list, Primitive, PrimitiveList}; + use crate::runtime::RuntimePinnedFontGenericRole::Serif; + + let Some(out) = env::var_os("RITO_SCROLL_SEAMS_OUT") else { + eprintln!("RITO_SCROLL_SEAMS_OUT not set; seam lowering skipped."); + return; + }; + let lowered_dir = Path::new(&out).join("lowered"); + fs::create_dir_all(&lowered_dir).expect("create the lowered directory"); + let workspace = |relative: &str| format!("{}/../../{relative}", env!("CARGO_MANIFEST_DIR")); + let read = |relative: &str| fs::read(workspace(relative)).expect("workspace file reads"); + let tinos = read("apps/reader/src/assets/fonts/Tinos-Regular.ttf"); + let han = read("apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf"); + let alias = + |bytes: &[u8]| -> Arc { format!("__RitoPinned_{:x}", Sha256::digest(bytes)).into() }; + let families: Vec<(Arc, Arc)> = vec![ + (alias(&tinos), "Tinos".into()), + (alias(&han), "Source Han Serif CN".into()), + ]; + let normalize = |list: &mut PrimitiveList| { + for primitive in &mut list.commands { + match primitive { + Primitive::Text(run) | Primitive::Ruby(run) => { + if let Some((_, family)) = families + .iter() + .find(|(alias, _)| *alias == run.paint.font.family) + { + run.paint.font.family = Arc::clone(family); + } + } + Primitive::DrawImage { src, .. } | Primitive::ImageFallback { src, .. } => { + *src = "synthetic:gradient32".to_owned(); + } + _ => {} + } + } + }; + let both_faces = policy(vec![ + face(tinos.clone(), Serif, Some("und")), + face(han.clone(), Serif, Some("zh-Hans")), + ]); + // Each case with the fewest windows a chapter must take to be lowered. + let cases = [ + ( + "paragraphs", + long_paragraphs_fixture_epub(), + pinned_test_font_policy(), + 2, + ), + ( + "styled", + paint_command_kitchen_sink_fixture_epub(), + pinned_test_font_policy(), + 2, + ), + ( + "book01", + read("packages/rito/tests/fixtures/books/book-01.epub"), + both_faces.clone(), + 4, + ), + // Vertical-rl columns, whose windows run leftwards. + ("columns", vertical_book_fixture_epub(), both_faces, 3), + ]; + for (case, bytes, faces, fewest) in cases { + let mut document = + RuntimeDocument::open_with_pinned_font_policy(&bytes, faces).expect("case opens"); + let mut taken = 0; + for chapter in 0..document.document.package.spine.len() { + if taken == 2 { + break; + } + let Ok(build) = document.build_chapter_local_fragment_layout( + &seam_windows(), + chapter, + &PaginationTarget::Start, + ) else { + continue; + }; + let Ok(strip) = build.layout.chapter_strip(0) else { + continue; + }; + let windows = strip.windows(); + if windows.count() < fewest { + continue; + } + taken += 1; + let pairs = windows.count() - 1; + let mut firsts: Vec = (0..4).map(|step| step * pairs / 4).collect(); + firsts.dedup(); + for ratio in [1.0, 1.25, 1.5, 2.0, 2.625, 3.0] { + for &first in &firsts { + for (part, run) in [ + ("a", first..first + 1), + ("b", first + 1..first + 2), + ("ab", first..first + 2), + ] { + let rows = windows.device_rows(run.start, ratio).start + ..windows.device_rows(run.end - 1, ratio).end; + let length = (rows.end - rows.start) / ratio; + let (width, height, axis) = match windows.axis() { + ScrollAxis::Down => (seam_windows().page_width, length, "down"), + ScrollAxis::Left => (length, seam_windows().page_height, "left"), + }; + let mut lowered = strip + .lowered_window(run, ratio, &|href| document.image_size(href)) + .expect("window lowers"); + normalize(&mut lowered); + let name = format!("seam-{case}-c{chapter}-r{ratio}-w{first}-{part}"); + let encoded = + encode_reader_primitive_list(&lowered).expect("window encodes"); + fs::write(lowered_dir.join(format!("{name}.ritodl")), &encoded.bytes) + .expect("write the lowered list"); + let meta = json!({ + "name": name, + "width": width, + "height": height, + "axis": axis, + "ratio": ratio, + "primitiveCount": lowered.commands.len(), + }); + fs::write( + lowered_dir.join(format!("{name}.json")), + serde_json::to_string_pretty(&meta).expect("metadata is JSON"), + ) + .expect("write the lowered metadata"); + } + } + } + eprintln!( + "lowered {case} chapter {chapter}: {} windows", + windows.count() + ); + } + assert!( + taken > 0, + "{case} has a chapter of {fewest} windows or more" + ); + } +} + +/// The seam instrument's window: a phone-wide page, short enough that a +/// chapter takes several. +fn seam_windows() -> LayoutConfig { + create_layout_config(LayoutConfigInput { + width: 390.0, + height: 300.0, + margin: MarginInput::Axis { x: 20.0, y: 24.0 }, + spread: SpreadMode::Single, + first_page_alone: false, + spread_gap: 0.0, + root_font_size: 16.0, + line_height_override: None, + line_height_force: None, + font_family_override: None, + font_family_force: None, + device_pixel_ratio: 1.0, + }) +} diff --git a/crates/rito-core/src/runtime/tests/scrolled_revision_tests.rs b/crates/rito-core/src/runtime/tests/scrolled_revision_tests.rs new file mode 100644 index 000000000..42ed4f5df --- /dev/null +++ b/crates/rito-core/src/runtime/tests/scrolled_revision_tests.rs @@ -0,0 +1,395 @@ +//! A scrolled revision: every chapter one strip, its pages the strip's +//! windows, painted by the strip and moved onto their own origins. + +use super::fixture::{ + layout, long_paragraphs_fixture_epub, multi_chapter_fixture_epub, pinned_test_font_policy, +}; +use super::pinned_font_policy_fixtures::{face, policy, serif_text_font}; +use crate::interaction::{TextCaretAddress, TextCaretAffinity}; +use crate::layout::{LayoutConfig, SpreadMode}; +use crate::runtime::page_artifact::{ + FragmentPageArtifact, PageArtifactSourcePoint, PageArtifactTextCaretQuery, + PageArtifactTextCaretResolution, +}; +use crate::runtime::source_locator::PaginationTarget; +use crate::runtime::{ + RuntimeDocument, RuntimePageReadingAnchor, RuntimePinnedFontGenericRole, RuntimeSourceLocator, + RuntimeSourceLocatorResolution, RuntimeSourcePoint, +}; + +fn scrolled() -> LayoutConfig { + let mut layout = layout(); + layout.spread_mode = SpreadMode::Scrolled; + layout.font_family_override = Some("serif".to_owned()); + layout.font_family_force = Some(true); + layout +} + +fn document() -> RuntimeDocument { + RuntimeDocument::open_with_pinned_font_policy( + &multi_chapter_fixture_epub(), + policy(vec![face( + serif_text_font(), + RuntimePinnedFontGenericRole::Serif, + Some("en"), + )]), + ) + .expect("multi-chapter document opens") +} + +/// The text of each run an artifact records, in order. +fn run_texts(artifact: &FragmentPageArtifact) -> Vec { + let text: Vec = artifact.page_text().encode_utf16().collect(); + artifact + .interaction_runs() + .iter() + .map(|run| String::from_utf16_lossy(&text[run.start..run.end])) + .collect() +} + +#[test] +fn a_scrolled_revision_pages_each_chapter_as_the_windows_of_its_strip() { + let mut document = document(); + let summary = document + .create_revision(&scrolled()) + .expect("scrolled revision is created"); + let revision = document + .revisions + .get(&summary.revision_id) + .expect("revision is retained"); + let layout = &revision.fragment_layout; + let chapters: Vec<(usize, usize)> = layout + .chapters() + .map(|(chapter, start)| (start, chapter.page_count)) + .collect(); + assert!(chapters.len() > 1); + for (chapter_index, (start, page_count)) in chapters.into_iter().enumerate() { + let strip = layout.chapter_strip(chapter_index).expect("strip lays out"); + assert_eq!(page_count, strip.windows().count()); + let recorded: Vec = (start..start + page_count) + .flat_map(|page| { + run_texts( + &layout + .page(page) + .expect("page is published") + .artifact_handle(), + ) + }) + .collect(); + assert_eq!(recorded, run_texts(&strip.whole_artifact())); + } +} + +#[test] +fn a_scrolled_spread_is_its_window_framed_by_the_strip() { + let mut document = document(); + let summary = document + .create_revision(&scrolled()) + .expect("scrolled revision is created"); + let revision = document + .revisions + .get(&summary.revision_id) + .expect("revision is retained"); + let layout = &revision.fragment_layout; + let strip = layout.chapter_strip(0).expect("strip lays out"); + let session = revision.chapter_engine_session(); + for ratio in [1.0, 2.625] { + for window in 0..strip.windows().count() { + let frame = session + .frame(window, ratio) + .expect("frame paints") + .expect("spread is published"); + assert_eq!(frame.page_indexes, [window]); + assert_eq!( + frame.device_origin, + strip.windows().device_origin(window, ratio) + ); + let (commands, _) = strip + .window_frame(strip.whole(), window..window + 1, ratio) + .expect("window paints"); + assert_eq!(frame.commands.to_vec(), commands); + } + } +} + +#[test] +fn a_scrolled_chapter_is_laid_out_whole_for_its_first_window() { + let mut document = document(); + let build = document + .build_chapter_local_fragment_layout(&scrolled(), 0, &PaginationTarget::Start) + .expect("chapter builds"); + let layout = &build.layout; + let strip = layout.chapter_strip(0).expect("strip lays out"); + assert!(layout.is_complete(), "nothing of a strip is left to resume"); + assert_eq!(layout.page_count(), strip.windows().count()); +} + +/// Each line start of every window: its source point, and the window's +/// content-box point at the start of its first run. +fn line_starts( + revision: &crate::runtime::RuntimeRevision, +) -> Vec<(usize, PageArtifactSourcePoint, (f64, f64))> { + let session = revision.chapter_engine_session(); + let layout = &revision.fragment_layout; + let mut starts = Vec::new(); + for page in 0..layout.page_count() { + let artifact = layout + .page(page) + .expect("page is published") + .artifact_handle(); + for run in artifact + .interaction_runs() + .iter() + .filter(|run| run.run_index == 0) + { + let Some(caret) = session.text_caret(TextCaretAddress { + page_index: page, + block_index: run.block_index, + line_index: run.line_index, + run_index: 0, + char_index: 0, + affinity: TextCaretAffinity::Downstream, + }) else { + continue; + }; + starts.push((page, caret.source_point, (run.x, run.y))); + } + } + starts +} + +/// The source point a reader reads at the reading line of `page` shown from +/// `offset`, at the inline position `x`. +fn read_at_reading_line( + revision: &crate::runtime::RuntimeRevision, + page: usize, + offset: f64, + x: f64, +) -> PageArtifactSourcePoint { + let reading_line = offset - revision.layout_config.margin_top; + match revision + .chapter_engine_session() + .resolve_text_caret(PageArtifactTextCaretQuery { + page_index: page, + x: x + 0.5, + y: reading_line + 0.5, + }) { + Some(PageArtifactTextCaretResolution::Resolved(caret)) => caret.source_point, + other => panic!("page {page}: nothing at the reading line {reading_line}: {other:?}"), + } +} + +fn locator(href: &str, point: &PageArtifactSourcePoint) -> RuntimeSourceLocator { + RuntimeSourceLocator { + href: href.to_owned(), + anchor_id: None, + source_point: Some(RuntimeSourcePoint { + node_path: point.node_path.clone(), + text_offset: point.text_offset, + }), + source_range: None, + progression: None, + } +} + +fn resolved_page( + document: &mut RuntimeDocument, + revision_id: &str, + locator: RuntimeSourceLocator, +) -> usize { + match document + .resolve_source_locator(revision_id, locator) + .expect("locator resolves") + { + RuntimeSourceLocatorResolution::Resolved { page_index, .. } => page_index, + pending => panic!("locator is pending: {pending:?}"), + } +} + +fn chapter_href(document: &mut RuntimeDocument, revision_id: &str) -> String { + match document + .get_page_reading_anchor(revision_id, 0) + .expect("anchor request is valid") + { + RuntimePageReadingAnchor::Resolved { locator, .. } => locator.href, + other => panic!("first page has no reading anchor: {other:?}"), + } +} + +#[test] +fn every_line_start_resolves_to_its_window_and_reads_back_at_the_reading_line() { + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &long_paragraphs_fixture_epub(), + pinned_test_font_policy(), + ) + .expect("fixture opens"); + let revision_id = document + .create_revision(&scrolled()) + .expect("scrolled revision is created") + .revision_id; + let href = chapter_href(&mut document, &revision_id); + let starts = line_starts(&document.revisions[&revision_id]); + assert!(starts.len() > 100); + for (page, point, _) in &starts { + let resolved = resolved_page(&mut document, &revision_id, locator(&href, point)); + assert_eq!( + resolved, *page, + "{point:?} resolves to the window it starts in" + ); + } + let revision = &document.revisions[&revision_id]; + for (page, point, (x, _)) in &starts { + let offset = revision + .chapter_engine_session() + .target_block_offset(*page, point, 1.0) + .expect("the window draws the point"); + assert_eq!(read_at_reading_line(revision, *page, offset, *x), *point); + } +} + +#[test] +fn a_pages_reading_point_lands_in_a_window_and_reads_back_to_the_page() { + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &long_paragraphs_fixture_epub(), + pinned_test_font_policy(), + ) + .expect("fixture opens"); + let mut paged = layout(); + paged.font_family_override = Some("serif".to_owned()); + paged.font_family_force = Some(true); + let paged_id = document + .create_revision(&paged) + .expect("paged revision") + .revision_id; + let scrolled_id = document + .create_revision(&scrolled()) + .expect("scrolled revision") + .revision_id; + let page_count = document.revisions[&paged_id].fragment_layout.page_count(); + for page in 0..page_count { + let RuntimePageReadingAnchor::Resolved { + locator: anchor, .. + } = document + .get_page_reading_anchor(&paged_id, page) + .expect("anchor request is valid") + else { + panic!("page {page} has a reading anchor"); + }; + let point = anchor + .source_point + .clone() + .expect("the anchor is a source point"); + let point = PageArtifactSourcePoint { + node_path: point.node_path, + text_offset: point.text_offset, + }; + let window = resolved_page(&mut document, &scrolled_id, anchor.clone()); + let revision = &document.revisions[&scrolled_id]; + let offset = revision + .chapter_engine_session() + .target_block_offset(window, &point, 1.0) + .expect("the window draws the anchor"); + let read = read_at_reading_line(revision, window, offset, 0.0); + let back = resolved_page(&mut document, &paged_id, locator(&anchor.href, &read)); + assert_eq!(back, page, "page {page} reads back from window {window}"); + } +} + +#[test] +fn a_kept_locator_stays_on_the_reading_line_through_ten_relayouts() { + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &long_paragraphs_fixture_epub(), + pinned_test_font_policy(), + ) + .expect("fixture opens"); + let first = document + .create_revision(&scrolled()) + .expect("scrolled revision") + .revision_id; + let href = chapter_href(&mut document, &first); + let starts = line_starts(&document.revisions[&first]); + let (_, kept, _) = starts[starts.len() / 2].clone(); + for relayout in 0..10 { + let mut config = scrolled(); + if relayout % 2 == 1 { + config.viewport_width = 360.0; + config.page_width = 360.0; + } + let revision_id = document + .create_revision(&config) + .expect("relayout") + .revision_id; + let window = resolved_page(&mut document, &revision_id, locator(&href, &kept)); + let revision = &document.revisions[&revision_id]; + let session = revision.chapter_engine_session(); + let offset = session + .target_block_offset(window, &kept, 1.0) + .expect("the window draws the kept point"); + let read = read_at_reading_line(revision, window, offset, 0.0); + assert_eq!( + session.target_block_offset(window, &read, 1.0), + Some(offset), + "relayout {relayout}: the reading line holds the kept point's line" + ); + assert!( + read.node_path <= kept.node_path + && (read.node_path < kept.node_path || read.text_offset <= kept.text_offset) + ); + } +} + +#[test] +fn a_whole_book_table_warms_each_scrolled_chapter_before_it_lays_its_strip_out() { + use crate::runtime::revision::RevisionProgress; + + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &long_paragraphs_fixture_epub(), + pinned_test_font_policy(), + ) + .expect("fixture opens"); + let whole = document + .create_revision(&scrolled()) + .expect("scrolled revision is created") + .revision_id; + let pages = document.revisions[&whole].fragment_layout.page_count(); + let mut build = document.begin_revision(&scrolled()).expect("build begins"); + let mut steps = 0; + let stepped = loop { + steps += 1; + match document + .advance_revision(build, 1) + .expect("a step lays out") + { + RevisionProgress::Pending(next) => build = *next, + RevisionProgress::Done(summary) => break summary.revision_id, + } + }; + // A chapter paginates a page a step, pages shorter than its windows, + // before its windows are taken one a step: at least twice the steps a + // table of windows alone would take. + assert!(steps >= 2 * pages, "{steps} steps for {pages} windows"); + let layout = &document.revisions[&stepped].fragment_layout; + assert_eq!(layout.page_count(), pages); + for page in 0..pages { + assert_eq!( + run_texts( + &layout + .page(page) + .expect("page is published") + .artifact_handle() + ), + run_texts( + &document.revisions[&whole] + .fragment_layout + .page(page) + .expect("page is published") + .artifact_handle() + ), + ); + } + // Each chapter says how long its strip runs once the strip is laid out. + for (index, (chapter, _)) in layout.chapters().enumerate() { + let strip = layout.chapter_strip(index).expect("strip lays out"); + assert_eq!(chapter.extent, Some(strip.windows().extent())); + } +} diff --git a/crates/rito-core/src/runtime/tests/strip_cost_probe.rs b/crates/rito-core/src/runtime/tests/strip_cost_probe.rs new file mode 100644 index 000000000..ff3090129 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/strip_cost_probe.rs @@ -0,0 +1,654 @@ +//! Measures what laying each chapter of a corpus out as one continuous +//! strip costs, beside what paginating it costs today: the numbers a +//! scrolled reading mode is designed against. +//! +//! Ignored; run in release with the corpus and the demo reader's fonts: +//! +//! ```text +//! RITO_STRIP_PROBE_BOOKS=~/rito-corpus RITO_STRIP_PROBE_FONTS=apps/reader/src/assets/fonts \ +//! RITO_STRIP_PROBE_OUT=strip.tsv [RITO_STRIP_PROBE_ONLY=001,003] [RITO_STRIP_PROBE_CHAPTER=4] \ +//! cargo test -p rito-core --release --lib strip_cost_probe::strip_cost_probe -- --ignored --nocapture +//! ``` +//! +//! (`strip_cost_probe::session_open_cost_probe`, +//! `strip_cost_probe::background_step_probe` and +//! `strip_cost_probe::window_text_probe` take the same variables and +//! `RITO_STRIP_PROBE_ONLY`; they all write the same report, so run one at a +//! time. `window_text_probe` also takes `RITO_STRIP_PROBE_DUMP=`, where +//! it writes both record trees of a window that differs.) +//! +//! Every chapter is measured at a desktop and a phone viewport, each pass +//! on a document opened afresh so no chapter starts with another pass's +//! caches. One row per chapter and viewport: +//! - `open_ms`: today's cold open, through the chapter's first page; +//! - `strip_cold_ms`: the whole chapter laid out continuously right after, +//! without text (what a cold scrolled open adds); +//! - `strip_text_ms`: the same with every line's text, its fragments and +//! their estimated heap bytes; +//! - `paged_ms` / `page_max_ms`: paginating the rest of the chapter page by +//! page, as the background does, in total and its slowest page; +//! - `seal_ms`: the continuous layout after that pagination; +//! - `window_ms` / `lower_ms`: a middle page's text and paint at the +//! viewport's ratio, then its lowering and encoding (a window the size of +//! the viewport does the same work). + +use std::{ + collections::HashSet, + env, fs, + mem::size_of, + path::{Path, PathBuf}, + rc::Rc, + time::Instant, +}; + +use rito_fragment::{ + CancelFlag, ClusterPosition, ConstraintSpace, FormattingContext, FormattingTree, Fragment, + LineText, TextSegment, +}; + +use super::pinned_font_policy_fixtures::{face, policy}; +use crate::fragment_pagination::{chapter_is_vertical, ChapterPaginator}; +use crate::layout::{ + create_layout_config, LayoutConfig, LayoutConfigInput, MarginInput, SpreadMode, +}; +use crate::render::{encode_reader_primitive_list, lower}; +use crate::runtime::source_locator::PaginationTarget; +use crate::runtime::{RuntimeDocument, RuntimePinnedFontGenericRole, RuntimePinnedFontPolicyInput}; + +struct Viewport { + name: &'static str, + width: f64, + height: f64, + margin: f64, + ratio: f64, +} + +const VIEWPORTS: [Viewport; 2] = [ + Viewport { + name: "desktop", + width: 1500.0, + height: 950.0, + margin: 50.0, + ratio: 1.0, + }, + Viewport { + name: "phone", + width: 390.0, + height: 844.0, + margin: 20.0, + ratio: 3.0, + }, +]; + +#[test] +#[ignore = "corpus measurement; see the module docs"] +fn strip_cost_probe() { + let books = PathBuf::from(env::var("RITO_STRIP_PROBE_BOOKS").expect("RITO_STRIP_PROBE_BOOKS")); + let fonts = PathBuf::from(env::var("RITO_STRIP_PROBE_FONTS").expect("RITO_STRIP_PROBE_FONTS")); + let out = env::var("RITO_STRIP_PROBE_OUT").expect("RITO_STRIP_PROBE_OUT"); + let only = env::var("RITO_STRIP_PROBE_ONLY").ok(); + let mut paths = fs::read_dir(&books) + .expect("corpus dir reads") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "epub")) + .filter(|path| { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + only.as_deref() + .is_none_or(|only| only.split(',').any(|prefix| name.starts_with(prefix))) + }) + .collect::>(); + paths.sort(); + let mut rows = String::from( + "book\tchapter\tviewport\tvertical\tpages\tstrip_len\topen_ms\tstrip_cold_ms\t\ + strip_text_ms\ttext_fragments\ttext_bytes\tpaged_ms\tpage_max_ms\tseal_ms\t\ + window_ms\tlower_ms\twindow_size\terror\n", + ); + for path in paths { + let book = path.file_name().unwrap_or_default().to_string_lossy(); + let book = book.split(' ').next().unwrap_or_default().to_owned(); + let bytes = fs::read(&path).expect("book reads"); + let chapters = match open(&bytes, &fonts) { + Ok(document) => document.chapter_count(), + Err(error) => { + eprintln!("{book}: does not open: {error}"); + continue; + } + }; + let only_chapter = env::var("RITO_STRIP_PROBE_CHAPTER") + .ok() + .and_then(|value| value.parse::().ok()); + for chapter in + (0..chapters).filter(|chapter| only_chapter.is_none_or(|only| only == *chapter)) + { + for viewport in &VIEWPORTS { + let row = measure(&bytes, &fonts, chapter, viewport) + .unwrap_or_else(|error| format!("{}\t{error}", "\t".repeat(14))); + let line = format!("{book}\t{chapter}\t{}\t{row}\n", viewport.name); + eprint!("{line}"); + rows.push_str(&line); + } + } + fs::write(&out, &rows).expect("report writes"); + } +} + +fn measure( + bytes: &[u8], + fonts: &Path, + chapter: usize, + viewport: &Viewport, +) -> Result { + let config = layout_config(viewport); + let content_width = config.page_width - config.margin_left - config.margin_right; + let content_height = config.page_height - config.margin_top - config.margin_bottom; + + // Today's cold open, then the strip on what it left behind. + let mut document = open(bytes, fonts)?; + let started = Instant::now(); + let build = + document.build_chapter_local_fragment_layout(&config, chapter, &PaginationTarget::Start)?; + let open_ms = ms(started); + let built = build.layout.materialized_tree(0).ok_or("no chapter tree")?; + let engine = document + .fragment_engine_handle() + .ok_or("no fragment engine")?; + let tree = &built.tree; + let vertical = chapter_is_vertical(tree); + let (strip_cold_ms, strip_len, _) = strip( + &engine.engine, + tree, + content_width, + content_height, + LineText::Omitted, + )?; + let (strip_text_ms, _, root) = strip( + &engine.engine, + tree, + content_width, + content_height, + LineText::All, + )?; + let mut heap = Heap::default(); + heap.add(&root); + drop((root, build, engine, built, document)); + + // The background's pagination, then the seal on what it left behind. + let mut document = open(bytes, fonts)?; + let mut build = + document.build_chapter_local_fragment_layout(&config, chapter, &PaginationTarget::Start)?; + let built = build.layout.materialized_tree(0).ok_or("no chapter tree")?; + let engine = document + .fragment_engine_handle() + .ok_or("no fragment engine")?; + let tree = &built.tree; + let mut paginator = ChapterPaginator::new(tree, content_width, content_height, false); + let (mut pages, mut paged_ms, mut page_max_ms) = (0_usize, 0.0_f64, 0.0_f64); + loop { + let started = Instant::now(); + let page = paginator + .next_page(&engine.engine, tree, &CancelFlag::new()) + .map_err(|error| error.message().to_owned())?; + let step = ms(started); + if page.is_none() { + break; + } + pages += 1; + // The first page was laid out by the open: it is not background work. + if pages > 1 { + paged_ms += step; + page_max_ms = page_max_ms.max(step); + } + } + let (seal_ms, _, _) = strip( + &engine.engine, + tree, + content_width, + content_height, + LineText::Omitted, + )?; + + // A middle page shown at the viewport's ratio. + build + .layout + .extend_chapter(None) + .map_err(|error| error.message().to_owned())?; + let page = build + .layout + .page(build.layout.page_count() / 2) + .ok_or("no middle page")?; + let started = Instant::now(); + let commands = page + .commands_for(viewport.ratio) + .map_err(|error| error.message().to_owned())?; + let window_ms = ms(started); + let started = Instant::now(); + let lowered = lower(commands.iter(), viewport.ratio, &|href| { + document.image_size(href) + }) + .map_err(|error| error.to_string())?; + let encoded = encode_reader_primitive_list(&lowered).map_err(|error| format!("{error:?}"))?; + let lower_ms = ms(started); + let window_size = format!("{}/{}", lowered.commands.len(), encoded.bytes.len()); + + Ok(format!( + "{vertical}\t{pages}\t{strip_len:.0}\t{open_ms:.2}\t{strip_cold_ms:.2}\t{strip_text_ms:.2}\t\ + {}\t{}\t{paged_ms:.2}\t{page_max_ms:.2}\t{seal_ms:.2}\t{window_ms:.2}\t{lower_ms:.2}\t{window_size}\t", + heap.fragments, heap.bytes + )) +} + +/// Lays the whole chapter out in one continuous block, as wide as a page's +/// content box and knowing how tall one is: its time, its block length and +/// its root. +fn strip( + engine: &dyn FormattingContext, + tree: &FormattingTree, + content_width: f64, + content_height: f64, + line_text: LineText, +) -> Result<(f64, f64, Fragment), String> { + let vertical = chapter_is_vertical(tree); + let (inline, block) = if vertical { + (content_height, content_width) + } else { + (content_width, content_height) + }; + let space = ConstraintSpace { + fragmentainer_size: Some(block), + line_text, + ..ConstraintSpace::continuous(inline) + }; + let started = Instant::now(); + let outcome = engine + .layout(tree, tree.root(), &space, None, &CancelFlag::new()) + .map_err(|error| format!("strip layout: {error:?}"))?; + let elapsed = ms(started); + if outcome.continuation.is_some() { + return Err("a continuous layout broke".to_owned()); + } + let root = outcome.fragments.root; + let length = match &root { + Fragment::Box(fragment) => fragment.rect.height, + _ => 0.0, + }; + Ok((elapsed, length, root)) +} + +/// Fragments a tree holds and the heap bytes they own, shared tables +/// counted once. +#[derive(Default)] +struct Heap { + fragments: usize, + bytes: usize, + shared: HashSet, +} + +impl Heap { + fn add(&mut self, fragment: &Fragment) { + self.fragments += 1; + match fragment { + Fragment::Box(fragment) => { + self.bytes += fragment.children.capacity() * size_of::(); + fragment.children.iter().for_each(|child| self.add(child)); + } + Fragment::Line(fragment) => { + self.bytes += fragment.children.capacity() * size_of::() + + fragment.text_runs.capacity() * size_of::<(u32, u32)>(); + fragment.children.iter().for_each(|child| self.add(child)); + } + Fragment::Text(fragment) => { + self.slice::(&fragment.clusters); + if let Some(segments) = &fragment.segments { + self.slice::(segments); + } + } + Fragment::Image(_) => {} + } + } + + fn slice(&mut self, slice: &Rc<[T]>) { + if self.shared.insert(Rc::as_ptr(slice).cast::() as usize) { + self.bytes += 2 * size_of::() + slice.len() * size_of::(); + } + } +} + +fn open(bytes: &[u8], fonts: &Path) -> Result { + RuntimeDocument::open_with_pinned_font_policy(bytes, demo_faces(fonts)) + .map_err(|error| error.message().to_owned()) +} + +fn demo_faces(fonts: &Path) -> RuntimePinnedFontPolicyInput { + let read = |file: &str| fs::read(fonts.join(file)).expect("font reads"); + use RuntimePinnedFontGenericRole::{SansSerif, Serif}; + policy(vec![ + face(read("Tinos-Regular.ttf"), Serif, Some("und")), + face(read("SourceHanSerifCN-Regular.otf"), Serif, Some("zh-Hans")), + face(read("Arimo-Regular.ttf"), SansSerif, Some("und")), + face( + read("SourceHanSansCN-Regular.otf"), + SansSerif, + Some("zh-Hans"), + ), + ]) +} + +fn layout_config(viewport: &Viewport) -> LayoutConfig { + create_layout_config(LayoutConfigInput { + width: viewport.width, + height: viewport.height, + margin: MarginInput::All(viewport.margin), + spread: SpreadMode::Single, + first_page_alone: false, + spread_gap: 0.0, + root_font_size: 16.0, + line_height_override: None, + line_height_force: None, + font_family_override: None, + font_family_force: None, + device_pixel_ratio: viewport.ratio, + }) +} + +fn ms(started: Instant) -> f64 { + started.elapsed().as_secs_f64() * 1000.0 +} + +/// What a reader session takes to open each chapter of `RITO_STRIP_PROBE_ONLY` +/// at its end, cold: the work the wasm build is timed on for the same +/// chapters (`RITO_STRIP_PROBE_OUT` lists them with their times). +#[test] +#[ignore = "corpus measurement; see the module docs"] +fn session_open_cost_probe() { + use crate::runtime::{ + ReaderArtifactRequest, ReaderLayout, ReaderLocator, ReaderSession, ReaderSpreadMode, + ReaderTextRenderingProfile, + }; + let books = PathBuf::from(env::var("RITO_STRIP_PROBE_BOOKS").expect("RITO_STRIP_PROBE_BOOKS")); + let fonts = PathBuf::from(env::var("RITO_STRIP_PROBE_FONTS").expect("RITO_STRIP_PROBE_FONTS")); + let out = env::var("RITO_STRIP_PROBE_OUT").expect("RITO_STRIP_PROBE_OUT"); + let only = env::var("RITO_STRIP_PROBE_ONLY").expect("RITO_STRIP_PROBE_ONLY"); + let mut rows = String::from("book\thref\tms\n"); + for prefix in only.split(',') { + let path = fs::read_dir(&books) + .expect("corpus dir reads") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .find(|path| { + path.extension().is_some_and(|ext| ext == "epub") + && path + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with(prefix)) + }) + .expect("book is in the corpus"); + let bytes = fs::read(&path).expect("book reads"); + let document = open(&bytes, &fonts).expect("book opens"); + let hrefs = document + .document + .chapters + .iter() + .map(|chapter| chapter.href.clone()) + .collect::>(); + drop(document); + for href in hrefs { + let mut best = f64::INFINITY; + for _ in 0..3 { + let mut session = ReaderSession::open_owned_with_pinned_font_policy( + 1, + bytes.clone(), + demo_faces(&fonts), + ) + .expect("session opens"); + let started = Instant::now(); + let artifact = session.request_artifact(ReaderArtifactRequest { + session_id: 1, + request_id: 1, + layout: ReaderLayout { + render_ratio: 1.0, + viewport_width: 1500.0, + viewport_height: 950.0, + margin_top: 50.0, + margin_right: 50.0, + margin_bottom: 50.0, + margin_left: 50.0, + spread_mode: ReaderSpreadMode::Single, + first_page_alone: false, + spread_gap: 0.0, + root_font_size: 16.0, + line_height_override: None, + font_family_override: None, + line_height_force: false, + font_family_force: false, + }, + locator: ReaderLocator { + href: href.clone(), + anchor_id: None, + source_point: None, + source_range: None, + progression: Some(0.999), + }, + text_profile: ReaderTextRenderingProfile::PlatformStringRuns, + }); + best = best.min(ms(started)); + if artifact.is_err() { + best = f64::NAN; + break; + } + } + let line = format!("{prefix}\t{href}\t{best:.2}\n"); + eprint!("{line}"); + rows.push_str(&line); + } + } + fs::write(&out, rows).expect("report writes"); +} + +/// What each step of a whole-book layout costs on the thread a host draws +/// on: every book at both viewports, paginated and scrolled, stepped by the +/// frame quantum the hosts ask for, each step timed. One row per book, +/// viewport and mode: the steps, their total, their slowest, their 99th +/// percentile, and how many took longer than a 60 Hz frame. Takes the same +/// variables as [`strip_cost_probe`]. +#[test] +#[ignore = "corpus measurement; see the module docs"] +fn background_step_probe() { + use crate::layout::SpreadMode; + use crate::runtime::reader_session::READER_FRAME_QUANTUM_PAGES; + use crate::runtime::revision::RevisionProgress; + + let books = PathBuf::from(env::var("RITO_STRIP_PROBE_BOOKS").expect("RITO_STRIP_PROBE_BOOKS")); + let fonts = PathBuf::from(env::var("RITO_STRIP_PROBE_FONTS").expect("RITO_STRIP_PROBE_FONTS")); + let out = env::var("RITO_STRIP_PROBE_OUT").expect("RITO_STRIP_PROBE_OUT"); + let only = env::var("RITO_STRIP_PROBE_ONLY").ok(); + let mut paths = fs::read_dir(&books) + .expect("corpus dir reads") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "epub")) + .filter(|path| { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + only.as_deref() + .is_none_or(|only| only.split(',').any(|prefix| name.starts_with(prefix))) + }) + .collect::>(); + paths.sort(); + let mut rows = + String::from("book\tviewport\tmode\tsteps\ttotal_ms\tmax_ms\tp99_ms\tover_16\terror\n"); + for path in paths { + let book = path.file_name().unwrap_or_default().to_string_lossy(); + let book = book.split(' ').next().unwrap_or_default().to_owned(); + let bytes = fs::read(&path).expect("book reads"); + for viewport in &VIEWPORTS { + for (mode, spread) in [ + ("paged", SpreadMode::Single), + ("scrolled", SpreadMode::Scrolled), + ] { + let mut config = layout_config(viewport); + config.spread_mode = spread; + let row = (|| -> Result { + let mut document = open(&bytes, &fonts)?; + let mut build = document + .begin_revision(&config) + .map_err(|error| error.message().to_owned())?; + let mut times = Vec::new(); + loop { + let started = Instant::now(); + let progress = document + .advance_revision(build, READER_FRAME_QUANTUM_PAGES as usize) + .map_err(|error| error.message().to_owned())?; + times.push(ms(started)); + match progress { + RevisionProgress::Pending(next) => build = *next, + RevisionProgress::Done(_) => break, + } + } + let total: f64 = times.iter().sum(); + let over = times.iter().filter(|time| **time > 16.0).count(); + times.sort_by(f64::total_cmp); + let max = times.last().copied().unwrap_or_default(); + let p99 = times[(times.len() * 99 / 100).min(times.len() - 1)]; + Ok(format!( + "{}\t{total:.1}\t{max:.2}\t{p99:.2}\t{over}\t", + times.len() + )) + })() + .unwrap_or_else(|error| format!("\t\t\t\t\t{error}")); + let line = format!("{book}\t{}\t{mode}\t{row}\n", viewport.name); + eprint!("{line}"); + rows.push_str(&line); + } + } + } + fs::write(&out, rows).expect("report writes"); +} + +/// Whether laying a window's text out around it reads as the whole strip +/// laid out with its text does, and what it costs: every horizontal chapter +/// of every book at both viewports, each window's text laid out on the +/// strip laid out without it, its frame (at the viewport's ratio) and its +/// records compared with the whole text strip's. One row per chapter and +/// viewport: its windows, how many frames and records differ, the slowest +/// and 99th-percentile window, and the most text fragments a window holds +/// beside the whole strip's. Takes the same variables as +/// [`strip_cost_probe`]. +#[test] +#[ignore = "corpus measurement; see the module docs"] +fn window_text_probe() { + let books = PathBuf::from(env::var("RITO_STRIP_PROBE_BOOKS").expect("RITO_STRIP_PROBE_BOOKS")); + let fonts = PathBuf::from(env::var("RITO_STRIP_PROBE_FONTS").expect("RITO_STRIP_PROBE_FONTS")); + let out = env::var("RITO_STRIP_PROBE_OUT").expect("RITO_STRIP_PROBE_OUT"); + let only = env::var("RITO_STRIP_PROBE_ONLY").ok(); + let mut paths = fs::read_dir(&books) + .expect("corpus dir reads") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "epub")) + .filter(|path| { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + only.as_deref() + .is_none_or(|only| only.split(',').any(|prefix| name.starts_with(prefix))) + }) + .collect::>(); + paths.sort(); + let mut rows = String::from( + "book\tchapter\tviewport\twindows\tframes_differ\trecords_differ\tmax_ms\tp99_ms\t\ + window_text_max\twhole_text\terror\n", + ); + for path in paths { + let book = path.file_name().unwrap_or_default().to_string_lossy(); + let book = book.split(' ').next().unwrap_or_default().to_owned(); + let bytes = fs::read(&path).expect("book reads"); + for viewport in &VIEWPORTS { + let mut config = layout_config(viewport); + config.spread_mode = SpreadMode::Scrolled; + let lines = window_text_rows(&bytes, &fonts, &config, viewport.ratio) + .unwrap_or_else(|error| vec![format!("\t\t\t\t\t\t\t\t{error}")]); + for line in lines { + let (chapter, rest) = line.split_once('\t').unwrap_or((&line, "")); + let line = format!("{book}\t{chapter}\t{}\t{rest}\n", viewport.name); + eprint!("{line}"); + rows.push_str(&line); + } + } + fs::write(&out, &rows).expect("report writes"); + } +} + +/// One row per chapter (from its index on) for [`window_text_probe`]. +fn window_text_rows( + bytes: &[u8], + fonts: &Path, + config: &LayoutConfig, + ratio: f64, +) -> Result, String> { + let mut document = open(bytes, fonts)?; + let revision_id = document + .create_revision(config) + .map_err(|error| error.message)? + .revision_id; + let layout = &document.revisions[&revision_id].fragment_layout; + let engine = layout.strip_engine().ok_or("no build inputs")?; + let mut rows = Vec::new(); + for chapter in 0..layout.chapters().count() { + let strips = layout + .chapter_strip_laid_out(chapter, false) + .and_then(|plain| Ok((plain, layout.chapter_strip_laid_out(chapter, true)?))); + let (plain, whole) = match strips { + Ok(strips) => strips, + Err(error) => { + rows.push(format!("{chapter}\t\t\t\t\t\t\t\t{}", error.message())); + continue; + } + }; + let (mut frames, mut records, mut most) = (0, 0, 0); + let mut times = Vec::new(); + for window in 0..plain.windows().count() { + let started = Instant::now(); + let text = plain + .window_text(engine, window) + .map_err(|error| error.message().to_owned())?; + times.push(ms(started)); + most = most.max(text_fragments(&text)); + let frame = |strip: &crate::runtime::scroll_strip::ChapterStrip, text: &Fragment| { + strip + .window_frame(text, window..window + 1, ratio) + .map(|(commands, _)| commands) + .map_err(|error| error.message().to_owned()) + }; + if frame(&plain, &text)? != frame(&whole, whole.whole())? { + frames += 1; + } + if plain.window_text_page_root(&text, window) != whole.window_page_root(window) { + records += 1; + if let Ok(dir) = env::var("RITO_STRIP_PROBE_DUMP") { + let _ = fs::write( + format!("{dir}/c{chapter}w{window}-plain.txt"), + format!("{:#?}", plain.window_text_page_root(&text, window)), + ); + let _ = fs::write( + format!("{dir}/c{chapter}w{window}-whole.txt"), + format!("{:#?}", whole.window_page_root(window)), + ); + } + } + } + times.sort_by(f64::total_cmp); + let max = times.last().copied().unwrap_or_default(); + let p99 = times + .get((times.len() * 99 / 100).min(times.len().saturating_sub(1))) + .copied() + .unwrap_or_default(); + rows.push(format!( + "{chapter}\t{}\t{frames}\t{records}\t{max:.2}\t{p99:.2}\t{most}\t{}\t", + times.len(), + text_fragments(whole.whole()) + )); + } + Ok(rows) +} + +fn text_fragments(fragment: &Fragment) -> usize { + match fragment { + Fragment::Text(_) => 1, + Fragment::Box(inner) => inner.children.iter().map(text_fragments).sum(), + Fragment::Line(line) => line.children.iter().map(text_fragments).sum(), + Fragment::Image(_) => 0, + } +} diff --git a/crates/rito-core/src/runtime/tests/stylesheet_scope_tests.rs b/crates/rito-core/src/runtime/tests/stylesheet_scope_tests.rs new file mode 100644 index 000000000..55178654f --- /dev/null +++ b/crates/rito-core/src/runtime/tests/stylesheet_scope_tests.rs @@ -0,0 +1,61 @@ +//! Which stylesheets a chapter of a publication opened from its archive +//! lays out under: the ones it links (and embeds), as a browser applies +//! them — a chapter without a `` takes no publication stylesheet, +//! however the package lists them (a book's unlinked note page keeps its +//! UA paragraphs where its `Style.css` indents every other chapter's). + +use rito_block::BlockFormattingContext; +use rito_fragment::{CancelFlag, Fragment, LineText}; +use rito_inline::ParleyInlineContext; + +use super::pinned_font_policy_fixtures::{bundled_font, content_epub_linking, layout}; +use crate::fragment_pagination::layout_chapter_strip; +use crate::runtime::RuntimeDocument; + +/// The first line's inline offset within its paragraph. +fn first_line_x(linked: bool) -> f64 { + let bytes = content_epub_linking( + "zh-CN", + "

文字

", + "p { margin: 0; text-indent: 2em; }", + None, + linked, + ); + let mut document = RuntimeDocument::open_pinned_for_tests(&bytes).expect("document opens"); + let revision_id = document + .create_revision(&layout()) + .expect("revision") + .revision_id; + let inline = ParleyInlineContext::new(vec![bundled_font("SourceHanSerifCN-Regular.otf")]) + .expect("font registers"); + let engine = BlockFormattingContext::new(inline); + let built = document + .chapter_formatting_tree(&revision_id, "chapter") + .expect("tree builds"); + let strip = layout_chapter_strip( + &engine, + &built.tree, + 372.0, + 592.0, + LineText::All, + &CancelFlag::new(), + ) + .expect("strip lays out"); + fn first_line(fragment: &Fragment) -> Option { + match fragment { + Fragment::Box(block) => block.children.iter().find_map(first_line), + Fragment::Line(line) => line + .children + .first() + .map(|child| line.rect.x + child.rect().x), + Fragment::Text(_) | Fragment::Image(_) => None, + } + } + first_line(&strip).expect("a line") +} + +#[test] +fn a_chapter_takes_only_the_stylesheets_it_links() { + assert_eq!(first_line_x(true), 32.0, "the linked sheet indents 2em"); + assert_eq!(first_line_x(false), 0.0, "an unlinked sheet does not apply"); +} diff --git a/crates/rito-core/src/runtime/tests/vertical_geometry_tests.rs b/crates/rito-core/src/runtime/tests/vertical_geometry_tests.rs new file mode 100644 index 000000000..9b28881a5 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/vertical_geometry_tests.rs @@ -0,0 +1,729 @@ +//! Where a vertical-rl page puts its boxes and its glyphs, as Chromium 156 +//! lays them out: the pinned serif at 18px with `line-height: 1.5`, on the +//! fixture page (a 372 × 592 content box 24px in from the page's top-left +//! corner), the chapter's body without margins. Every paragraph and +//! heading carries a background so its border box paints; the expected +//! boxes are the elements' `getBoundingClientRect()` in a 372 × 592 +//! viewport. Each column's glyphs hang from the line's central baseline: +//! the line-over edge less the line's central ascent (half the font's +//! whole-pixel height, rounded up, plus the floored half-leading) plus +//! half that height, less half the em; each sits its face's vertical +//! origin (0.88 em) below its place down the column. A canvas drawing the +//! glyphs there matches Chromium's DOM column pixel for pixel. + +use super::fixture::layout; +use super::pinned_font_policy_fixtures::{bundled_font, content_epub, face, policy}; +use crate::render::DisplayCommand; +use crate::runtime::{RuntimeDocument, RuntimePinnedFontGenericRole}; + +/// The page's content-box origin on the fixture page. +const ORIGIN: f64 = 24.0; + +/// What the first page paints, in the content box's coordinates: each +/// decorated box's border box, each column of text's glyph origin across +/// and its first and last glyph origins down, and each ruby annotation's +/// glyph origin across. +struct Painted { + blocks: Vec<[f64; 4]>, + columns: Vec<[f64; 3]>, + annotations: Vec<[f64; 1]>, + /// The family every column run and annotation paints in. + families: Vec, +} + +/// The pinned faces: the serif alone, or — with `latin_primary` — Tinos +/// ahead of it, so the Latin face is every run's primary font and the +/// ideographs fall back to the serif. +fn painted(body: &str, css: &str, latin_primary: bool) -> Painted { + painted_page(0, body, css, latin_primary) +} + +/// What page `page_index` paints, as [`painted`] reads the first. +fn painted_page(page_index: usize, body: &str, css: &str, latin_primary: bool) -> Painted { + let bytes = content_epub( + "ja", + body, + &format!( + "html {{ writing-mode: vertical-rl; }} \ + body {{ margin: 0; font-size: 18px; line-height: 1.5; }} \ + p, h1 {{ background: #eee; }} {css}" + ), + None, + ); + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &bytes, + policy( + latin_primary + .then(|| { + face( + bundled_font("Tinos-Regular.ttf"), + RuntimePinnedFontGenericRole::Serif, + None, + ) + }) + .into_iter() + .chain([face( + bundled_font("SourceHanSerifCN-Regular.otf"), + RuntimePinnedFontGenericRole::Serif, + Some("ja"), + )]) + .collect(), + ), + ) + .expect("document opens"); + let summary = document.create_revision(&layout()).expect("revision"); + let fragment_layout = &document.revisions[&summary.revision_id].fragment_layout; + let page = fragment_layout.page(page_index).expect("page lays out"); + let commands = page.commands_for(1.0).expect("page paints"); + let mut painted = Painted { + blocks: Vec::new(), + columns: Vec::new(), + annotations: Vec::new(), + families: Vec::new(), + }; + for command in commands.iter() { + match command { + DisplayCommand::PaintBlock { rect, .. } => { + painted + .blocks + .push([rect.x - ORIGIN, rect.y - ORIGIN, rect.width, rect.height]) + } + DisplayCommand::PaintText(text) => { + painted.families.push(text.paint.font.family.to_string()); + // A column's text may come in several commands, one per + // stretch layout placed; they read as one column. + if let (Some(first), Some(last)) = (text.clusters.first(), text.clusters.last()) { + let (x, top, bottom) = (first.x - ORIGIN, first.y - ORIGIN, last.y - ORIGIN); + match painted.columns.last_mut() { + Some(column) if (column[0] - x).abs() < 1e-6 && column[2] < top => { + column[2] = bottom; + } + _ => painted.columns.push([x, top, bottom]), + } + } + } + DisplayCommand::PaintRuby(ruby) => { + painted.families.push(ruby.paint.font.family.to_string()); + if let Some(first) = ruby.clusters.first() { + painted.annotations.push([first.x - ORIGIN]); + } + } + _ => {} + } + } + painted +} + +fn assert_close(case: &str, what: &str, got: &[[f64; N]], expected: &[[f64; N]]) { + let close = got.len() == expected.len() + && got + .iter() + .zip(expected) + .all(|(got, expected)| got.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6)); + assert!(close, "{case}: {what} {got:?}, Chromium {expected:?}"); +} + +struct Case { + name: &'static str, + css: &'static str, + body: &'static str, + latin_primary: bool, + blocks: &'static [[f64; 4]], + columns: &'static [[f64; 3]], + annotations: &'static [[f64; 1]], +} + +const CASES: &[Case] = &[ + // The UA's `margin-block: 1em` lands on the physical right and left, + // and adjoining paragraphs collapse it to one 18px gap. + Case { + name: "ua-paragraphs", + css: "", + latin_primary: false, + body: "

一一一一一

二二二

三

", + blocks: &[ + [327.0, 0.0, 27.0, 592.0], + [282.0, 0.0, 27.0, 592.0], + [237.0, 0.0, 27.0, 592.0], + ], + columns: &[ + [332.0, 15.84, 87.84], + [287.0, 15.84, 51.84], + [242.0, 15.84, 15.84], + ], + annotations: &[], + }, + // Physical margins, padding and borders keep their sides: the right + // margin is block-start and collapses with the paragraph before, the + // top padding pushes the text down the column. + Case { + name: "box-edges", + css: "", + latin_primary: false, + body: "

一一

\ +

二二二

三

", + blocks: &[ + [327.0, 0.0, 27.0, 592.0], + [218.0, 5.0, 91.0, 562.0], + [260.0, 17.0, 27.0, 518.0], + [156.0, 0.0, 27.0, 592.0], + ], + columns: &[ + [332.0, 15.84, 33.84], + [265.0, 32.84, 68.84], + [161.0, 15.84, 15.84], + ], + annotations: &[], + }, + // A heading's 0.67em margins on the 1/64 grid. + Case { + name: "heading", + css: "", + latin_primary: false, + body: "

一一

二二二

", + blocks: &[ + [293.890625, 0.0, 54.0, 592.0], + [242.78125, 0.0, 27.0, 592.0], + ], + // The heading's 51px font height leaves one pixel of leading above + // its text box: its glyphs centre 26.5px in from the line's edge. + columns: &[[303.390625, 31.68, 67.68], [247.78125, 15.84, 51.84]], + annotations: &[], + }, + // The body's top and bottom margins shorten the columns. + Case { + name: "body-margin", + css: "body { margin: 8px 20px 30px 40px; }", + latin_primary: false, + body: "

一一

", + blocks: &[[325.0, 8.0, 27.0, 554.0]], + columns: &[[330.0, 23.84, 41.84]], + annotations: &[], + }, + // The indent starts the first column 1em down; the second column + // wraps to the left of it. + Case { + name: "indent-and-wrap", + css: "", + latin_primary: false, + body: "

\ + 一一一一一一一一一一一一一一一一一一一一一一一一一\ + 一一一一一一一一一一一一一一一一一一一一一一一一一

", + blocks: &[[300.0, 0.0, 54.0, 592.0]], + columns: &[[332.0, 33.84, 573.84], [305.0, 15.84, 339.84]], + annotations: &[], + }, + // A top margin is an inline-start indent; a top padding moves the + // text but not the box. + Case { + name: "start-indent", + css: ".start-3em { margin-top: 3em; }", + latin_primary: false, + body: "

一一一

二二

", + blocks: &[[327.0, 54.0, 27.0, 538.0], [282.0, 0.0, 27.0, 592.0]], + columns: &[[332.0, 69.84, 105.84], [287.0, 51.84, 69.84]], + annotations: &[], + }, + // A Latin primary font: the run's text box and alphabetic baseline + // are Tinos's (16px ascent, 20px tall at 18px), and each ideograph + // centres on its own font's central baseline, 8px back towards the + // line-over edge from there: 11px in from the line's edge. + Case { + name: "latin-primary", + css: "", + latin_primary: true, + body: "

一一一

", + blocks: &[[327.0, 0.0, 27.0, 592.0]], + columns: &[[334.0, 15.84, 51.84]], + annotations: &[], + }, + // A first line's annotation stands on its base's em box, reaching + // into the paragraph's 1em top margin: the line does not grow. Its + // 13px-high text box rides the half pixel an odd height leaves, so its + // glyphs centre at 355. + Case { + name: "ruby-borrows-the-margin", + css: "", + latin_primary: false, + body: RUBY_BODY, + blocks: &[ + [327.0, 0.0, 27.0, 592.0], + [282.0, 0.0, 27.0, 592.0], + [237.0, 0.0, 27.0, 592.0], + ], + columns: RUBY_18_COLUMNS, + annotations: &[[350.5]], + }, + Case { + name: "ruby-borrows-the-margin-latin-primary", + css: "body { font-size: 16px; line-height: 1.75; }", + latin_primary: true, + body: RUBY_BODY, + blocks: &[ + [328.0, 0.0, 28.0, 592.0], + [284.0, 0.0, 28.0, 592.0], + [240.0, 0.0, 28.0, 592.0], + ], + columns: RUBY_16_LATIN_COLUMNS, + annotations: &[[351.5]], + }, + // Without a margin to reach into the line grows by the 2.5px the + // annotation stands past its strut. + Case { + name: "ruby-grows-the-line", + css: "body { font-size: 16px; line-height: 1.75; } p { margin: 0; }", + latin_primary: true, + body: RUBY_BODY, + blocks: &[ + [341.5, 0.0, 30.5, 592.0], + [313.5, 0.0, 28.0, 592.0], + [285.5, 0.0, 28.0, 592.0], + ], + columns: RUBY_16_LATIN_P0_COLUMNS, + annotations: &[[365.0]], + }, + // An inner line's annotation reaches into the 6px the line before + // leaves under its text's em box: the paragraph keeps its three 28px + // lines. + Case { + name: "inner-ruby-latin-primary", + css: "body { font-size: 16px; line-height: 1.75; } p { margin: 0; }", + latin_primary: true, + body: INNER_RUBY_BODY, + blocks: &[[288.0, 0.0, 84.0, 592.0], [260.0, 0.0, 28.0, 592.0]], + columns: &[ + [351.5, 14.08, 590.08], + [323.5, 14.08, 590.08], + [295.5, 14.08, 46.08], + [267.5, 14.08, 46.08], + ], + annotations: &[[339.5]], + }, + Case { + name: "inner-ruby", + css: "body { font-size: 16px; line-height: 1.75; } p { margin: 0; }", + latin_primary: false, + body: INNER_RUBY_BODY, + blocks: &[[288.0, 0.0, 84.0, 592.0], [260.0, 0.0, 28.0, 592.0]], + columns: &[ + [350.5, 14.08, 590.08], + [322.5, 14.08, 590.08], + [294.5, 14.08, 46.08], + [266.5, 14.08, 46.08], + ], + annotations: &[[338.5]], + }, + // An opener after a stop or a closer gives up its blank top half, its + // vertical half width: 『 takes 36 to 45, and 三 starts at 81 where + // two full ems would put it at 90. + Case { + name: "vertical-punctuation-pairs", + css: "", + latin_primary: false, + body: "

一。『二』三

", + blocks: &[[327.0, 0.0, 27.0, 592.0]], + columns: &[[332.0, 15.84, 96.84]], + annotations: &[], + }, + // A minimum width is a minimum block size across vertical lines: an + // empty box keeps 40px of the page, a one-line paragraph 2em with its + // line on the line-over side, and a minimum wins over a smaller + // maximum (27px against 20px). + Case { + name: "vertical-min-max-block-size", + css: "div { background: #ccc; }", + latin_primary: false, + body: "
\ +

一

\ +
二二
\ +

三

", + blocks: &[ + [332.0, 0.0, 40.0, 592.0], + [296.0, 0.0, 36.0, 592.0], + [269.0, 0.0, 27.0, 592.0], + [242.0, 0.0, 27.0, 592.0], + ], + columns: &[ + [310.0, 15.84, 15.84], + [274.0, 15.84, 33.84], + [247.0, 15.84, 15.84], + ], + annotations: &[], + }, + // An empty block that takes block space hands the space under the + // line before it on to the next paragraph's annotations, so that + // paragraph's line keeps its height; an empty block with no size + // closes the space off and the next annotation grows its line by 6px. + Case { + name: "annotation-space-through-empty-blocks", + css: "p { margin: 0; }", + latin_primary: false, + body: "

一

宝ほう生

\ +

二に三

", + blocks: &[ + [345.0, 0.0, 27.0, 592.0], + [309.0, 0.0, 36.0, 592.0], + [282.0, 0.0, 27.0, 592.0], + [282.0, 0.0, 0.0, 592.0], + [249.0, 0.0, 33.0, 592.0], + ], + columns: &[ + [350.0, 15.84, 15.84], + [287.0, 15.84, 33.84], + [254.0, 15.84, 33.84], + ], + // The 9px annotations' 13px text boxes span 303.5 to 316.5 and + // 270.5 to 283.5 in Chromium's DOM. + annotations: &[[305.5], [272.5]], + }, + // A margin collapsed above an empty block is space it hands on too: + // behind an 8px margin the empty block's 36px and the margin lend the + // annotation all it needs, while the second empty block, a first child + // given nothing, lends nothing and the line grows by 6px. + Case { + name: "annotation-space-from-a-collapsed-margin", + css: "p { margin: 0; }", + latin_primary: false, + body: "

\ +

宝ほう生

\ +

二に三

", + blocks: &[ + [328.0, 0.0, 36.0, 592.0], + [301.0, 0.0, 27.0, 592.0], + [265.0, 0.0, 36.0, 592.0], + [232.0, 0.0, 33.0, 592.0], + ], + columns: &[[306.0, 15.84, 33.84], [237.0, 15.84, 33.84]], + // The annotations' text boxes span 322.5 to 335.5 and 253.5 to + // 266.5 in Chromium's DOM. + annotations: &[[324.5], [255.5]], + }, + // Upright kana shape top to bottom: the horizontal kerning the face + // gives アッ or ルパ does not apply, every glyph a whole em on. + Case { + name: "kana-shape-top-to-bottom", + css: "body { font-size: 16px; line-height: 1.75; } p { margin: 0; }", + latin_primary: true, + body: "

アップルパイも美味しいけど

", + blocks: &[[344.0, 0.0, 28.0, 592.0]], + columns: &[[351.5, 14.08, 206.08]], + annotations: &[], + }, +]; + +/// A full first line, then a ruby on the second. +const INNER_RUBY_BODY: &str = "

一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一\ + 二二漢字かんじ二二二二二二二二二二二二二二二二二二二二二二二二二二二二二二二二二\ + 三三三

四四四

"; + +const RUBY_BODY: &str = "

一一漢字かんじ一一一

二二二

三

"; + +#[test] +fn a_vertical_rl_page_places_boxes_and_glyphs_as_chromium_does() { + for case in CASES { + let painted = painted(case.body, case.css, case.latin_primary); + assert_close(case.name, "boxes", &painted.blocks, case.blocks); + assert_close(case.name, "columns", &painted.columns, case.columns); + assert_close( + case.name, + "annotations", + &painted.annotations, + case.annotations, + ); + // Upright glyphs draw in their face's vertical forms. + assert!( + painted + .families + .iter() + .all(|family| family.starts_with("__RitoVert_")), + "{}: {:?}", + case.name, + painted.families + ); + } +} + +/// The ruby paragraph's column at 18px, then the two paragraphs after. +const RUBY_18_COLUMNS: &[[f64; 3]] = &[ + [332.0, 15.84, 123.84], + [287.0, 15.84, 51.84], + [242.0, 15.84, 15.84], +]; + +/// The same at 16px under a Latin primary font: the ideographs centre +/// 12.5px in from each line's edge. +const RUBY_16_LATIN_COLUMNS: &[[f64; 3]] = &[ + [335.5, 14.08, 110.08], + [291.5, 14.08, 46.08], + [247.5, 14.08, 14.08], +]; + +/// The same with the ruby line grown by 2.5px. +const RUBY_16_LATIN_P0_COLUMNS: &[[f64; 3]] = &[ + [349.0, 14.08, 110.08], + [321.0, 14.08, 46.08], + [293.0, 14.08, 14.08], +]; + +/// What each text command of the first page draws, one entry per glyph +/// origin: its family, the origin in the content box's coordinates, and +/// the horizontal scale a transform wrapped around it applies. +fn painted_strings(body: &str) -> Vec<(String, f64, f64, f64)> { + painted_commands(body) + .into_iter() + .flat_map(|(family, origins, scale)| { + origins + .into_iter() + .map(move |(x, y)| (family.clone(), x, y, scale)) + }) + .collect() +} + +/// One text command as painted: its family, its glyph origins in the +/// content box's coordinates, and the horizontal scale a transform wrapped +/// around it applies. +type PaintedCommand = (String, Vec<(f64, f64)>, f64); + +/// What each text command of the first page draws. +fn painted_commands(body: &str) -> Vec { + let bytes = content_epub( + "ja", + body, + "html { writing-mode: vertical-rl; } body { margin: 0; font-size: 18px; line-height: 1.5; }", + None, + ); + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &bytes, + policy(vec![face( + bundled_font("SourceHanSerifCN-Regular.otf"), + RuntimePinnedFontGenericRole::Serif, + Some("ja"), + )]), + ) + .expect("document opens"); + let summary = document.create_revision(&layout()).expect("revision"); + let fragment_layout = &document.revisions[&summary.revision_id].fragment_layout; + let commands = fragment_layout + .page(0) + .expect("first page") + .commands_for(1.0) + .expect("page paints"); + let mut scale = 1.0; + let mut strings = Vec::new(); + for command in commands.iter() { + match command { + DisplayCommand::Transform { transforms, .. } => { + for transform in transforms { + if let crate::render::contract::ReaderTransform::Scale { sx, .. } = transform { + scale = *sx; + } + } + } + DisplayCommand::PopState => scale = 1.0, + DisplayCommand::PaintText(text) => strings.push(( + text.paint.font.family.to_string(), + text.clusters + .iter() + .map(|cluster| (cluster.x - ORIGIN, cluster.y - ORIGIN)) + .collect(), + scale, + )), + _ => {} + } + } + strings +} + +/// A `text-combine-upright: all` item sets as one horizontal string in an +/// em of the column, the box as wide as the font's 26px height over the +/// line's central baseline (341, so the box spans 328 to 354), the string +/// centred in it on the 1/64 grid and its baseline 21 - (26 - 18) / 2 = +/// 17px below the unit's top: `!?` in its own forms, `AB` (24.8px) in its +/// half-width forms, `2024` (39.6px, still 36 in half widths) scaled to +/// 1.1em from 3.1px in. Every character after a unit sits one em further +/// down. Measured on Chromium's DOM: a canvas drawing the strings there +/// matches it pixel for pixel. +#[test] +fn a_vertical_line_sets_combined_upright_items_as_chromium_does() { + let combine = |text: &str| format!("{text}"); + let strings = painted_strings(&format!( + "

一{}二{}三{}四

", + combine("!?"), + combine("AB"), + combine("2024") + )); + let expected: [(&str, f64, f64, f64); 7] = [ + ("vert", 332.0, 15.84, 1.0), + ("own", 334.328125, 35.0, 1.0), + ("vert", 332.0, 51.84, 1.0), + ("half", 332.0, 71.0, 1.0), + ("vert", 332.0, 87.84, 1.0), + ("own", 331.1, 107.0, 0.5), + ("vert", 332.0, 123.84, 1.0), + ]; + let close = strings.len() == expected.len() + && strings + .iter() + .zip(&expected) + .all(|((family, x, y, scale), (forms, ex, ey, escale))| { + let forms_match = match *forms { + "vert" => family.starts_with("__RitoVert_"), + "half" => family.starts_with("__RitoHWID_"), + _ => ["__RitoVert_", "__RitoHWID_", "__RitoTWID_", "__RitoQWID_"] + .iter() + .all(|variant| !family.starts_with(variant)), + }; + forms_match + && (x - ex).abs() < 1e-5 + && (y - ey).abs() < 1e-5 + && (scale - escale).abs() < 1e-5 + }); + assert!(close, "painted {strings:?}, Chromium {expected:?}"); +} + +/// A combined unit is one em a line never breaks inside: after 31 ems of +/// a 592px column, the two ideographs `一二` combined still fit (558 to +/// 576), where as two characters they would not, and the next character +/// opens the next column. +#[test] +fn a_combined_unit_takes_one_em_of_a_column_and_never_splits() { + let strings = painted_strings(&format!( + "

{}一二三

", + "一".repeat(31) + )); + let tail: Vec<(f64, f64)> = strings + .iter() + .rev() + .take(2) + .rev() + .map(|(_, x, y, _)| (*x, *y)) + .collect(); + assert_eq!(strings.len(), 33, "{strings:?}"); + assert!( + (tail[0].1 - 575.0).abs() < 1e-6 + && (tail[1].0 - 305.0).abs() < 1e-6 + && (tail[1].1 - 15.84).abs() < 1e-6, + "the unit and the character after it: {tail:?}" + ); +} + +/// A span whose writing mode is not its line's lays out as an inline-block +/// across the column (Blink's `StyleAdjuster`): `!!` sets as a horizontal +/// line 11.09375px long in a box 1.2em (21.59375px on the 1/64 grid) down +/// the column, hung from a central baseline synthesized off the box, its +/// larger half (5.546875px) over the line's baseline at 341. The text's +/// baseline lies its font's 21px ascent less the 3px its negative half +/// leading floors to below the box's top. A span without a line height of +/// its own takes the paragraph's 27px. Measured on Chromium's DOM. +#[test] +fn a_vertical_line_sets_an_orthogonal_span_across_it_as_chromium_does() { + let commands = painted_commands( + "

一!!二\ + !?三

", + ); + let firsts: Vec<(f64, f64)> = commands + .iter() + .filter_map(|(_, origins, _)| origins.first().copied()) + .collect(); + let expected = [ + (332.0, 15.84), + (335.453125, 36.0), + (332.0, 55.43375), + (334.34375, 79.0), + (332.0, 100.43375), + ]; + let close = firsts.len() == expected.len() + && firsts + .iter() + .zip(&expected) + .all(|((x, y), (ex, ey))| (x - ex).abs() < 1e-6 && (y - ey).abs() < 1e-6); + assert!(close, "painted {firsts:?}, Chromium {expected:?}"); +} + +/// A minimum block size fragments like content: an empty paragraph 2em +/// wide with 10px left in the first column holds those 10px there and its +/// other 26px at the head of the next, the paragraph after it following. +/// Measured on Chromium's vertical multicol: the pieces span 362 to 372 and +/// 0 to 26 from the columns' right edges, the next paragraph 26 to 53. +#[test] +fn a_minimum_block_size_continues_on_the_next_page_as_chromium_breaks_it() { + let body = "

\ +

一

"; + let first = painted_page(0, body, "", false); + let second = painted_page(1, body, "", false); + assert_close( + "first page", + "boxes", + &first.blocks, + &[[0.0, 0.0, 10.0, 592.0]], + ); + assert_close( + "second page", + "boxes", + &second.blocks, + &[[346.0, 0.0, 26.0, 592.0], [319.0, 0.0, 27.0, 592.0]], + ); + assert_close( + "second page", + "columns", + &second.columns, + &[[324.0, 15.84, 15.84]], + ); +} + +/// An upright glyph from a face without vertical metrics advances down the +/// column by the face's whole-pixel height, as Chromium falls back: Tinos +/// at 16px is 14 + 3 = 17px tall, so with Tinos primary its section signs +/// take 17px each between the serif's one-em glyphs (Chromium's places 0, +/// 17, 33, 49 for the trimmed bracket, 57, 73 and 90). Each glyph hangs +/// from its face's vertical origin: Tinos, which has none, its whole-pixel +/// ascent of 14; the serif 0.88 em, its trimmed bracket half an em higher. +#[test] +fn an_upright_glyph_without_vertical_metrics_advances_its_face_height() { + let bytes = content_epub( + "ja", + "

§4.【廃§§

", + "html { writing-mode: vertical-rl; } \ + body { margin: 0; font-size: 16px; line-height: 1.75; }", + None, + ); + let mut document = RuntimeDocument::open_with_pinned_font_policy( + &bytes, + policy(vec![ + face( + bundled_font("Tinos-Regular.ttf"), + RuntimePinnedFontGenericRole::Serif, + None, + ), + face( + bundled_font("SourceHanSerifCN-Regular.otf"), + RuntimePinnedFontGenericRole::Serif, + Some("ja"), + ), + ]), + ) + .expect("document opens"); + let summary = document.create_revision(&layout()).expect("revision"); + let commands = document.revisions[&summary.revision_id] + .fragment_layout + .page(0) + .expect("first page") + .commands_for(1.0) + .expect("page paints"); + let downs: Vec = commands + .iter() + .filter_map(|command| match command { + DisplayCommand::PaintText(text) => Some(text), + _ => None, + }) + .flat_map(|text| text.clusters.iter().map(|cluster| cluster.y - ORIGIN)) + .collect(); + let expected = [14.0, 31.08, 47.08, 55.08, 71.08, 87.0, 104.0]; + let close = downs.len() == expected.len() + && downs + .iter() + .zip(&expected) + .all(|(got, want)| (got - want).abs() < 1e-4); + assert!(close, "painted {downs:?}, Chromium {expected:?}"); +} diff --git a/crates/rito-core/src/runtime/tests/vertical_scroll_tests.rs b/crates/rito-core/src/runtime/tests/vertical_scroll_tests.rs new file mode 100644 index 000000000..d56ce3346 --- /dev/null +++ b/crates/rito-core/src/runtime/tests/vertical_scroll_tests.rs @@ -0,0 +1,163 @@ +//! A scrolled book whose pages progress right to left: its vertical-rl +//! chapters run leftwards as strips, column after column, and a chapter +//! that runs across that axis is paginated, each page one window. + +use super::fixture::{layout, vertical_book_fixture_epub}; +use super::pinned_font_policy_fixtures::{bundled_font, face, policy}; +use crate::fragment_window::ScrollAxis; +use crate::layout::{LayoutConfig, SpreadMode}; +use crate::render::{encode_reader_primitive_list, Primitive, PrimitiveList}; +use crate::runtime::page_artifact::FragmentPageArtifact; +use crate::runtime::{RuntimeDocument, RuntimePinnedFontGenericRole}; + +const RATIOS: [f64; 4] = [1.0, 1.5, 2.625, 3.0]; + +fn scrolled() -> LayoutConfig { + let mut layout = layout(); + layout.spread_mode = SpreadMode::Scrolled; + layout +} + +fn document() -> RuntimeDocument { + RuntimeDocument::open_with_pinned_font_policy( + &vertical_book_fixture_epub(), + policy(vec![face( + bundled_font("SourceHanSerifCN-Regular.otf"), + RuntimePinnedFontGenericRole::Serif, + Some("zh"), + )]), + ) + .expect("vertical book opens") +} + +/// The text of each run an artifact records, in order. +fn run_texts(artifact: &FragmentPageArtifact) -> Vec { + let text: Vec = artifact.page_text().encode_utf16().collect(); + artifact + .interaction_runs() + .iter() + .map(|run| String::from_utf16_lossy(&text[run.start..run.end])) + .collect() +} + +#[test] +fn a_right_to_left_book_scrolls_its_vertical_chapter_leftwards_and_pages_its_plate() { + let mut document = document(); + let summary = document + .create_revision(&scrolled()) + .expect("scrolled revision is created"); + let layout = &document.revisions[&summary.revision_id].fragment_layout; + let chapters: Vec<_> = layout.chapters().map(|(chapter, _)| chapter).collect(); + // The horizontal plate runs across the book's axis: paginated, one + // window a page, as long as a page is wide. + let config = scrolled(); + assert_eq!(chapters[0].extent, None); + assert_eq!( + layout.chapter_extent(&chapters[0].idref), + Some(chapters[0].page_count as f64 * config.page_width) + ); + // The vertical chapter is one strip whose windows run leftwards. + let strip = layout.chapter_strip(1).expect("vertical strip lays out"); + let windows = strip.windows(); + assert_eq!(windows.axis(), ScrollAxis::Left); + assert!(windows.count() > 2, "the columns take several windows"); + assert_eq!(chapters[1].page_count, windows.count()); + assert_eq!(chapters[1].extent, Some(windows.extent())); + assert_eq!(windows.length(), config.page_width); +} + +#[test] +fn a_leftward_chapters_windows_record_each_run_once_in_order() { + let mut document = document(); + let summary = document + .create_revision(&scrolled()) + .expect("scrolled revision is created"); + let layout = &document.revisions[&summary.revision_id].fragment_layout; + let strip = layout.chapter_strip(1).expect("vertical strip lays out"); + let recorded: Vec = (0..strip.windows().count()) + .flat_map(|window| run_texts(&strip.window_artifact(window))) + .collect(); + assert_eq!(recorded, run_texts(&strip.whole_artifact())); +} + +/// A leftward window paints in its chapter's coordinates exactly what the +/// pair of it and the window right of it paints there, so moving each onto +/// its own origin by whole device columns tiles the chapter. +#[test] +fn a_leftward_window_draws_its_content_as_the_pair_with_the_window_right_of_it() { + let mut document = document(); + let summary = document + .create_revision(&scrolled()) + .expect("scrolled revision is created"); + let layout = &document.revisions[&summary.revision_id].fragment_layout; + let strip = layout.chapter_strip(1).expect("vertical strip lays out"); + let images = |href: &str| document.image_size(href); + for ratio in RATIOS { + let encoded = |primitive: &Primitive| { + encode_reader_primitive_list(&PrimitiveList { + ratio, + commands: vec![primitive.clone()], + }) + .expect("primitive encodes") + .bytes + }; + for first in 0..strip.windows().count() - 1 { + let left = strip + .lowered_content(first + 1..first + 2, ratio, &images) + .expect("window lowers"); + assert!(!left.commands.is_empty(), "window {} paints", first + 1); + let pair = strip + .lowered_content(first..first + 2, ratio, &images) + .expect("pair lowers"); + let pair: Vec> = pair.commands.iter().map(encoded).collect(); + let mut drawn = pair.iter(); + for primitive in &left.commands { + let bytes = encoded(primitive); + assert!( + drawn.any(|by_pair| *by_pair == bytes), + "ratio {ratio} window {}: {primitive:?} is not drawn by the pair", + first + 1 + ); + } + } + } +} + +#[test] +fn a_leftward_spread_is_its_window_framed_by_the_strip_and_a_plate_is_a_page() { + let mut document = document(); + let summary = document + .create_revision(&scrolled()) + .expect("scrolled revision is created"); + let revision = &document.revisions[&summary.revision_id]; + let layout = &revision.fragment_layout; + let session = revision.chapter_engine_session(); + let (_, start) = layout.chapters().nth(1).expect("the vertical chapter"); + let strip = layout.chapter_strip(1).expect("vertical strip lays out"); + for ratio in RATIOS { + let plate = session + .frame(0, ratio) + .expect("plate frames") + .expect("plate is published"); + assert_eq!(plate.device_origin, (0.0, 0.0)); + for window in 0..strip.windows().count() { + let frame = session + .frame(start + window, ratio) + .expect("window frames") + .expect("window is published"); + assert_eq!( + frame.device_origin, + strip.windows().device_origin(window, ratio) + ); + let (commands, origin) = strip + .window_frame(strip.whole(), window..window + 1, ratio) + .expect("window paints"); + assert_eq!(origin, frame.device_origin); + assert_eq!( + frame.commands.to_vec(), + commands, + "window {window} at {ratio}" + ); + } + } +} diff --git a/crates/rito-core/src/runtime/types.rs b/crates/rito-core/src/runtime/types.rs index 1f6761552..89e180b4f 100644 --- a/crates/rito-core/src/runtime/types.rs +++ b/crates/rito-core/src/runtime/types.rs @@ -188,6 +188,9 @@ pub struct RuntimeRequiredFontFace { pub shape_fingerprint: String, pub byte_length: usize, pub source_order: usize, + /// OpenType features (tag, value) the family paints with on. + #[serde(default)] + pub features: Vec<(String, u32)>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/rito-core/src/style/stylo_sources.rs b/crates/rito-core/src/style/stylo_sources.rs index af97e5252..9b1c7e325 100644 --- a/crates/rito-core/src/style/stylo_sources.rs +++ b/crates/rito-core/src/style/stylo_sources.rs @@ -86,16 +86,20 @@ pub(crate) fn select_stylo_sources( let has_external_source = author_stylesheets .iter() .any(|source| matches!(source, AuthorStylesheetSource::External { .. })); - let implicit_source_count = if has_external_source { - 0 - } else { + // A chapter of a publication opened from its archive takes the + // stylesheets it links and embeds and nothing else, as a browser lays + // it out (a chapter without a `` lays out under the UA sheet + // alone). A document built through the API without any link keeps the + // compatibility contract: every publication stylesheet precedes its + // embedded sheets. + let implicit = !has_external_source && !stylesheet_ledger.links_only(); + let implicit_source_count = if implicit { stylesheet_ledger.sources().len() + } else { + 0 }; let mut stylesheets = Vec::with_capacity(author_stylesheets.len() + implicit_source_count); - // Preserve the compatibility contract used by documents constructed - // through the public LoadedEpubDocument API: when XHTML has no link - // occurrence, every publication stylesheet precedes embedded sheets. - if !has_external_source { + if implicit { for source in stylesheet_ledger.sources() { let source_index = stylesheets.len(); let expanded = expand_css_imports( @@ -1097,6 +1101,7 @@ mod tests { manifest: Vec::new(), spine: Vec::new(), toc: Vec::new(), + page_progression: Default::default(), }, stylesheets: stylesheets .iter() diff --git a/crates/rito-core/src/xhtml.rs b/crates/rito-core/src/xhtml.rs index 6fdff807e..d7418f704 100644 --- a/crates/rito-core/src/xhtml.rs +++ b/crates/rito-core/src/xhtml.rs @@ -114,7 +114,9 @@ pub struct TextNode { #[derive(Debug, Clone, PartialEq)] pub struct ImageNode { pub src: String, - pub alt: String, + /// The `alt` text (or `aria-label`): `None` when the element has + /// neither, which a broken image shows differently from an empty one. + pub alt: Option, pub attributes: Option, pub source_ref: SourceRef, /// The node came from folding an `` wrapper whose diff --git a/crates/rito-core/src/xhtml/parser.rs b/crates/rito-core/src/xhtml/parser.rs index 765abfac1..0c17cb34a 100644 --- a/crates/rito-core/src/xhtml/parser.rs +++ b/crates/rito-core/src/xhtml/parser.rs @@ -317,7 +317,9 @@ fn convert_svg_image( (!src.is_empty()).then(|| { DocumentNode::Image(ImageNode { src, - alt: String::new(), + // An SVG `` that fails to load paints nothing, no + // broken-image icon: the folded node behaves as an empty alt. + alt: Some(String::new()), // `width` and `height` on `` are presentation attributes // that map to the CSS properties (SVG 2 §7.2), and they are // what sizes the SVG-wrapped image idiom: `width="100%"` fits @@ -442,12 +444,11 @@ fn svg_image_href(element: Node<'_>) -> Option { .map(ToOwned::to_owned) } -fn image_alt(element: Node<'_>) -> String { +fn image_alt(element: Node<'_>) -> Option { element .attribute("alt") .or_else(|| element.attribute("aria-label")) - .unwrap_or_default() - .to_owned() + .map(str::to_owned) } fn merge_unwrapped_child(child: &DocumentNode, inline: &ElementNode) -> DocumentNode { @@ -939,7 +940,7 @@ mod tests { panic!("expected image"); }; assert_eq!(image.src, "../Images/cover.jpg"); - assert_eq!(image.alt, ""); + assert_eq!(image.alt.as_deref(), Some("")); assert!(image.attributes.is_none()); } diff --git a/crates/rito-fragment/src/block.rs b/crates/rito-fragment/src/block.rs index 3581eb06d..72a460389 100644 --- a/crates/rito-fragment/src/block.rs +++ b/crates/rito-fragment/src/block.rs @@ -198,6 +198,7 @@ fn leaf_fragment(source: FormattingNodeId, y: f64, inline_size: f64, height: f64 height, }, children: Vec::new(), + cuts: crate::BlockCuts::NONE, }) } @@ -233,6 +234,7 @@ fn sealed( height: used_block_size, }, children, + cuts: crate::BlockCuts::NONE, }), } } diff --git a/crates/rito-fragment/src/cache.rs b/crates/rito-fragment/src/cache.rs index 067163dc8..c40587079 100644 --- a/crates/rito-fragment/src/cache.rs +++ b/crates/rito-fragment/src/cache.rs @@ -16,6 +16,13 @@ //! being resized asks for a new width every frame, and most paragraphs //! break into the same lines at the next one. //! +//! A layout asking for the text of a range of lines (`LineText::Within`) +//! is answered by the node's outcome without line text when the cache +//! holds one that places none of its lines in the range, and laid out with +//! all of its line text when it places all of them there: windows read +//! down a strip name a new range on every node, and only the nodes they +//! cut through are laid out for them. +//! //! Memory is bounded by a byte budget charged a fixed size per fragment of //! each outcome: deterministic across platforms, and counted by walking the //! fragments rather than encoding them. Least-recently-used entries are @@ -29,7 +36,7 @@ use crate::break_token::BreakToken; use crate::constraint_space::{ConstraintSpace, FloatBand, LineText}; use crate::context::{CancelFlag, FormattingContext, LayoutError, LayoutOutcome}; use crate::formatting_tree::FormattingTree; -use crate::fragment::Fragment; +use crate::fragment::{Fragment, LineFonts}; /// One cache lookup-or-compute result. #[derive(Clone, Debug, PartialEq)] @@ -60,6 +67,9 @@ struct SpaceKey { float_band: Option<[u64; 3]>, containing_block_size: Option, line_text: LineTextKey, + /// The preceding line's reach below its baseline, its fonts and its + /// half-leading. + preceding_line: Option<(u64, LineFonts, u64)>, } /// [`LineText`] by the bit patterns of its window. @@ -193,24 +203,10 @@ impl FragmentCache { cancel: &CancelFlag, read: impl FnOnce(&LayoutOutcome, bool) -> R, ) -> Result { + let space = &self.settle_line_text(tree, node, space, token); let key = cache_key(tree, node, space, token); self.clock += 1; - let held = if self.entries.contains_key(&key) { - Some(key.clone()) - } else if space.line_text != LineText::Omitted { - None - } else { - self.widths - .get(&without_inline_size(&key)) - .filter(|held| { - self.entries[*held] - .outcome - .inline_size_range - .is_some_and(|(lo, hi)| lo <= space.inline_size && space.inline_size < hi) - }) - .cloned() - }; - if let Some(held) = held { + if let Some(held) = self.held(&key, space) { self.touch(&held); return Ok(read(&self.entries[&held].outcome, true)); } @@ -234,6 +230,59 @@ impl FragmentCache { Ok(read(&entry.get().outcome, false)) } + /// The entry that answers `key`: its own, or, for a layout without line + /// text, one at another inline size that reports holding for this one. + fn held(&self, key: &CacheKey, space: &ConstraintSpace) -> Option { + if self.entries.contains_key(key) { + return Some(key.clone()); + } + if space.line_text != LineText::Omitted { + return None; + } + self.widths + .get(&without_inline_size(key)) + .filter(|held| { + self.entries[*held] + .outcome + .inline_size_range + .is_some_and(|(lo, hi)| lo <= space.inline_size && space.inline_size < hi) + }) + .cloned() + } + + /// `space`, its range of lines settled when the node's outcome without + /// line text is held: a range that reaches none of the lines it places + /// asks for no line text, one that reaches them all for all of it. The + /// lines a range names carry their text under either. + fn settle_line_text( + &self, + tree: &FormattingTree, + node: crate::FormattingNodeId, + space: &ConstraintSpace, + token: Option<&BreakToken>, + ) -> ConstraintSpace { + let LineText::Within { top, bottom } = space.line_text else { + return *space; + }; + let omitted = ConstraintSpace { + line_text: LineText::Omitted, + ..*space + }; + let Some(held) = self.held(&cache_key(tree, node, &omitted, token), &omitted) else { + return *space; + }; + let line_text = match self.entries[&held].outcome.fragments.root.line_top_span() { + None => LineText::Omitted, + Some((first, last)) if last < top || first > bottom => LineText::Omitted, + Some((first, last)) if top <= first && last <= bottom => LineText::All, + Some(_) => return *space, + }; + ConstraintSpace { + line_text, + ..*space + } + } + /// Drops every entry that depends on `node` of `tree` — i.e. every /// outcome recorded against this tree's fingerprint whose layout read /// the node. Call once per node whose resolved style changed. @@ -370,6 +419,7 @@ fn space_key(space: &ConstraintSpace) -> SpaceKey { float_band, containing_block_size, line_text, + preceding_line, } = *space; SpaceKey { inline_size: inline_size.to_bits(), @@ -390,6 +440,13 @@ fn space_key(space: &ConstraintSpace) -> SpaceKey { LineTextKey::Within([top, bottom].map(f64::to_bits)) } }, + preceding_line: preceding_line.map(|line| { + ( + line.below.to_bits(), + line.fonts, + line.half_leading.to_bits(), + ) + }), } } @@ -459,6 +516,128 @@ mod tests { FormattingTree::new(nodes, FormattingNodeId(count)).expect("valid tree") } + /// Five lines ten pixels apart, each holding a run when its line text + /// asks for it; notes the line text it is asked for. + struct LinesContext { + asked: std::cell::RefCell>, + } + + impl FormattingContext for LinesContext { + fn layout( + &self, + _tree: &FormattingTree, + node: FormattingNodeId, + space: &ConstraintSpace, + _token: Option<&BreakToken>, + _cancel: &CancelFlag, + ) -> Result { + self.asked.borrow_mut().push(space.line_text); + let lines = (0..5) + .map(|index| { + let top = 10.0 * f64::from(index); + Fragment::Line(crate::LineFragment { + source: node, + rect: crate::FragmentRect { + x: 0.0, + y: top, + width: space.inline_size, + height: 10.0, + }, + baseline: 8.0, + marker: None, + trailing_whitespace: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: crate::LineFonts::None, + text_runs: if space.line_text.at(top) { + vec![(0, 1)] + } else { + Vec::new() + }, + children: Vec::new(), + }) + }) + .collect(); + Ok(LayoutOutcome { + fragments: crate::FragmentTree { + root: Fragment::Box(crate::BoxFragment { + source: node, + rect: crate::FragmentRect { + x: 0.0, + y: 0.0, + width: space.inline_size, + height: 50.0, + }, + children: lines, + cuts: crate::BlockCuts::NONE, + }), + }, + continuation: None, + escaped_floats: Vec::new(), + positioned: Vec::new(), + inline_size_range: None, + }) + } + + fn intrinsic_inline_sizes( + &self, + _tree: &FormattingTree, + _node: FormattingNodeId, + ) -> Result { + Ok(crate::IntrinsicInlineSizes { + min_content: 10.0, + max_content: 100.0, + }) + } + } + + #[test] + fn a_range_of_lines_settles_on_the_outcome_without_line_text() { + let provider = LinesContext { + asked: std::cell::RefCell::new(Vec::new()), + }; + let tree = leaf_tree(0, &[10.0]); + let cancel = CancelFlag::new(); + let mut cache = FragmentCache::new(1 << 20); + let mut lay_out = |line_text| { + let space = ConstraintSpace { + line_text, + ..ConstraintSpace::continuous(300.0) + }; + cache + .layout(&provider, &tree, tree.root(), &space, None, &cancel) + .expect("layout succeeds") + }; + let within = |top, bottom| LineText::Within { top, bottom }; + let untexted = lay_out(LineText::Omitted); + // A range below every line is the layout without line text. + let below = lay_out(within(100.0, 200.0)); + assert!(below.from_cache); + assert_eq!(below.outcome, untexted.outcome); + // Ranges over every line are one layout with all of it. + assert!(!lay_out(within(-5.0, 45.0)).from_cache); + assert!(lay_out(within(-1.0, 41.0)).from_cache); + // A range through the lines is laid out for itself. + let through = lay_out(within(15.0, 25.0)); + assert!(!through.from_cache); + let Fragment::Box(root) = &through.outcome.fragments.root else { + panic!("a box root"); + }; + let texted: Vec = root + .children + .iter() + .filter(|line| matches!(line, Fragment::Line(line) if !line.text_runs.is_empty())) + .map(|line| line.rect().y) + .collect(); + assert_eq!(texted, [20.0]); + assert_eq!( + *provider.asked.borrow(), + [LineText::Omitted, LineText::All, within(15.0, 25.0)] + ); + } + #[test] fn replay_equals_recompute_and_skips_the_provider() { let provider = CountingContext::new(); diff --git a/crates/rito-fragment/src/constraint_space.rs b/crates/rito-fragment/src/constraint_space.rs index 9ecec5538..d6258ff5f 100644 --- a/crates/rito-fragment/src/constraint_space.rs +++ b/crates/rito-fragment/src/constraint_space.rs @@ -1,3 +1,5 @@ +use crate::fragment::LineFonts; + /// Typed layout constraints for one formatting-context invocation. /// /// A constraint space is a pure value: equal spaces plus an equal tree and @@ -30,6 +32,40 @@ pub struct ConstraintSpace { pub containing_block_size: Option, /// Which line boxes carry their text fragments. pub line_text: LineText, + /// The last line box of the in-flow block before this flow, when this + /// flow's first-line annotations may reuse the space under its text + /// (part of Blink's block-start annotation space). Only a flow with + /// ruby reads it. + pub preceding_line: Option, +} + +/// A line box above a flow's first line, as that line's annotations read +/// it: how far its box reaches below its baseline and which fonts shaped +/// its text. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrecedingLine { + /// Distance from the line's baseline to its box bottom, CSS px. + pub below: f64, + /// The fonts its glyph runs were shaped with. + pub fonts: LineFonts, + /// What it lends when it holds no glyph run, having no text to + /// measure under (see [`crate::LineFragment::glyphless_end_space`]). + pub half_leading: f64, + /// Its block-end annotation space (`LineFragment:: + /// annotation_space_end`), what an emphasis mark over the next line + /// may reuse; never negative, as an overflow under a block's last line + /// stays in that block. + pub annotation_space: f64, +} + +/// The half-leading of a line box `line_height` tall in a flow whose font +/// is `font_size`, on Blink's LayoutUnit grid: what an empty line (no +/// glyph runs) lends a following annotation, its content area being the +/// font size centred in the box. +pub fn empty_line_half_leading(line_height: f64, font_size: f64) -> f64 { + let height = (line_height * 64.0).round(); + let size = (font_size * 64.0).trunc(); + ((height - size).max(0.0) / 2.0).floor() / 64.0 } /// Which line boxes of a layout carry their text fragments. Without them a @@ -45,8 +81,13 @@ pub enum LineText { All, /// The lines whose box starts within this block range of the inline /// flow, in its own coordinates and ends included, do: the lines a - /// block container slicing the flow can place in one fragmentainer. - /// The others are laid out as under [`LineText::Omitted`]. + /// block container slicing the flow can place in one fragmentainer, + /// or the lines near one window of a continuous layout. Laid out on a + /// box, the range is in the box's coordinates and reaches the lines of + /// the flows inside it. Others may carry theirs too (a float's, placed + /// only once it is laid out, or every line of a flow the range covers + /// whole); a line without its text is laid out as under + /// [`LineText::Omitted`]. Within { top: f64, bottom: f64 }, } @@ -60,6 +101,42 @@ impl LineText { Self::Within { top: start, bottom } => start <= top && top <= bottom, } } + + /// The same lines, for a box or flow that starts `offset` down this + /// layout's block axis: a range moves into its coordinates. + #[must_use] + pub fn below(self, offset: f64) -> Self { + match self { + Self::Within { top, bottom } => Self::Within { + top: top - offset, + bottom: bottom - offset, + }, + other => other, + } + } + + /// The same lines, and those down to `by` further: for a flow whose + /// lines may move up by as much once placed. + #[must_use] + pub fn reaching(self, by: f64) -> Self { + match self { + Self::Within { top, bottom } => Self::Within { + top, + bottom: bottom + by, + }, + other => other, + } + } + + /// Every line, unless none: for a box whose place is only known once it + /// is laid out (a float), which a range cannot reach into. + #[must_use] + pub fn whole(self) -> Self { + match self { + Self::Omitted => Self::Omitted, + Self::All | Self::Within { .. } => Self::All, + } + } } /// One band of float exclusion at the top of a layout's block axis. @@ -83,6 +160,7 @@ impl ConstraintSpace { float_band: None, containing_block_size: None, line_text: LineText::All, + preceding_line: None, } } @@ -95,6 +173,7 @@ impl ConstraintSpace { float_band: None, containing_block_size: None, line_text: LineText::All, + preceding_line: None, } } } diff --git a/crates/rito-fragment/src/context.rs b/crates/rito-fragment/src/context.rs index 952b7176e..2f7540f13 100644 --- a/crates/rito-fragment/src/context.rs +++ b/crates/rito-fragment/src/context.rs @@ -151,4 +151,70 @@ pub trait FormattingContext { tree: &FormattingTree, node: FormattingNodeId, ) -> Result; + + /// Hands an inline provider the block layout its atomic inlines need: + /// an inline-block holding block-level boxes is a block container, + /// laid out by the block formatting context that owns the provider + /// (Blink lays an atomic inline out as a `BlockNode`). Providers with + /// no atomic inlines keep the default, which ignores it. + fn adopt_block_layout(&mut self, _delegate: BlockLayoutDelegate) {} +} + +/// Lays the block container `node` out in `space`, its inline flows laid +/// out by `inline`. +pub type AtomicBlockLayout = fn( + inline: &dyn FormattingContext, + tree: &FormattingTree, + node: FormattingNodeId, + space: &ConstraintSpace, + cancel: &CancelFlag, +) -> Result; + +/// The intrinsic inline sizes of the block container `node`, its inline +/// flows measured by `inline`. +pub type AtomicBlockSizes = fn( + inline: &dyn FormattingContext, + tree: &FormattingTree, + node: FormattingNodeId, +) -> Result; + +/// The block formatting context an inline provider lays an atomic inline's +/// block container out with, as plain functions of the provider itself: +/// the block context wraps the provider, so it cannot be reached as an +/// object from inside it. +#[derive(Clone, Copy)] +pub struct BlockLayoutDelegate { + /// Lays a block container out. + pub layout: AtomicBlockLayout, + /// Measures a block container's intrinsic inline sizes. + pub intrinsic_inline_sizes: AtomicBlockSizes, +} + +impl std::fmt::Debug for BlockLayoutDelegate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("BlockLayoutDelegate") + } +} + +/// A shared reference lays out as what it refers to, so a block context +/// can borrow the inline provider that called it. +impl FormattingContext for &T { + fn layout( + &self, + tree: &FormattingTree, + node: FormattingNodeId, + space: &ConstraintSpace, + token: Option<&BreakToken>, + cancel: &CancelFlag, + ) -> Result { + (**self).layout(tree, node, space, token, cancel) + } + + fn intrinsic_inline_sizes( + &self, + tree: &FormattingTree, + node: FormattingNodeId, + ) -> Result { + (**self).intrinsic_inline_sizes(tree, node) + } } diff --git a/crates/rito-fragment/src/formatting_tree.rs b/crates/rito-fragment/src/formatting_tree.rs index f84b1e749..afd899e3a 100644 --- a/crates/rito-fragment/src/formatting_tree.rs +++ b/crates/rito-fragment/src/formatting_tree.rs @@ -209,6 +209,26 @@ pub struct RubyAnnotation { /// The rt element's computed `ruby-align`, driving how the painted /// annotation distributes over the base extent. pub align: rito_style_contract::RubyAlign, + /// Whether the pair follows another pair of the same `` element, + /// inside that element's one box. + pub continues_ruby: bool, +} + +/// How an atomic inline (an image or an inline-block) aligns on its line, +/// from its own `vertical-align`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AtomAlign { + /// Its baseline on its parent's, shifted by the chain it sits in. + Baseline, + /// `top`: hangs from the line-box top. + Top, + /// `middle`: its middle half the parent's x-height above the parent's + /// baseline. `parent` is the style of the box it sits in, whose + /// primary font gives that x-height. + Middle { + /// The parent box's inline style. + parent: StyleId, + }, } /// One item of an inline formatting context's input, in content order. @@ -266,12 +286,12 @@ pub enum InlineItem { /// first, then the raster letterboxes inside it — and clamp /// bleed fills the inner sliver, not the outer margins. viewport: Option<(f64, f64)>, - /// `vertical-align: top`: the box pins to the line-box top, - /// ignoring the baseline-shift chain it sits in (a footnote + /// Its own `vertical-align`. `Top` pins the box to the line-box + /// top, ignoring the baseline-shift chain it sits in (a footnote /// badge inside `` stays at the line top while the sup's - /// strut still raises the envelope). It only grows the line + /// strut still raises the envelope); it only grows the line /// DOWNWARD when taller than the baseline envelope. - align_top: bool, + align: AtomAlign, /// Computed `object-fit` from the cascade (the UA stylesheet /// sets `contain` on `img`; authors can override). Consumed at /// paint time: `Fill` stretches into the box, everything else @@ -299,6 +319,24 @@ pub enum InlineItem { /// Accumulated baseline shift from `vertical-align` on ancestor /// inline boxes, CSS px; positive raises the box. baseline_shift_px: f64, + /// Its own `vertical-align`. + align: AtomAlign, + }, + /// The start edge of an inline box (Blink's open tag item): the box's + /// inline-start margin, border and padding, read from `style`, take + /// their width on the line ahead of the box's content and go to the + /// next line with it. Emitted only for a box whose start size is not + /// zero. + OpenTag { + /// The box's own inline style. + style: StyleId, + }, + /// The end edge of an inline box (Blink's close tag item): its + /// inline-end margin, border and padding, after its content and on the + /// same line as it. Emitted only for a box whose end size is not zero. + CloseTag { + /// The box's own inline style. + style: StyleId, }, /// A childless inline box (an empty `` footnote anchor). It has /// no advance and never breaks a line, but the open box still @@ -387,6 +425,10 @@ pub struct FormattingTree { /// inline style, whose line-height floors every line box the flow /// produces (CSS 2 §10.8.1). strut_styles: std::collections::BTreeMap, + /// The share of the root's top padding that is the collapsed margin + /// above its first in-flow child, CSS px (see + /// [`FormattingTree::root_leading_margin`]). + root_leading_margin: f64, fingerprint: u64, } @@ -411,6 +453,7 @@ impl FormattingTree { root, styles: None, strut_styles: std::collections::BTreeMap::new(), + root_leading_margin: 0.0, fingerprint, }) } @@ -439,6 +482,7 @@ impl FormattingTree { InlineItem::Image { style, layout_style, + align, .. } => { styles.inline.style(*style).map_err(|error| { @@ -447,10 +491,16 @@ impl FormattingTree { styles.layout.style(*layout_style).map_err(|error| { format!("formatting node {index} references a layout style outside the tree's table: {error}") })?; + if let AtomAlign::Middle { parent } = align { + styles.inline.style(*parent).map_err(|error| { + format!("formatting node {index} aligns by a parent style outside the tree's table: {error}") + })?; + } } InlineItem::InlineBlock { style, layout_style, + align, .. } => { styles.inline.style(*style).map_err(|error| { @@ -459,8 +509,15 @@ impl FormattingTree { styles.layout.style(*layout_style).map_err(|error| { format!("formatting node {index} references a layout style outside the tree's table: {error}") })?; + if let AtomAlign::Middle { parent } = align { + styles.inline.style(*parent).map_err(|error| { + format!("formatting node {index} aligns by a parent style outside the tree's table: {error}") + })?; + } } - InlineItem::EmptyBox { style, .. } => { + InlineItem::EmptyBox { style, .. } + | InlineItem::OpenTag { style } + | InlineItem::CloseTag { style } => { styles.inline.style(*style).map_err(|error| { format!("formatting node {index} references an inline style outside the tree's table: {error}") })?; @@ -481,6 +538,7 @@ impl FormattingTree { root, styles: Some(styles), strut_styles: std::collections::BTreeMap::new(), + root_leading_margin: 0.0, fingerprint, }) } @@ -505,6 +563,25 @@ impl FormattingTree { self.strut_styles = strut_styles; } + /// Records how much of the root's top padding is the collapsed margin + /// above its first in-flow child — a builder that carries the root's + /// margins as padding knows — and folds it into the fingerprint. + pub fn set_root_leading_margin(&mut self, px: f64) { + let mut mixer = FnvMixer::new(); + mixer.mix(&self.fingerprint.to_le_bytes()); + mixer.mix(&px.to_bits().to_le_bytes()); + self.fingerprint = mixer.finish(); + self.root_leading_margin = px; + } + + /// The share of the root's top padding that is the collapsed margin + /// above its first in-flow child: geometry treats it as padding, but a + /// ruby annotation on that child's first line reaches into it as into + /// a margin. + pub fn root_leading_margin(&self) -> f64 { + self.root_leading_margin + } + /// The strut style recorded for an inline-flow node, if any. pub fn strut_style(&self, node: FormattingNodeId) -> Option { self.strut_styles.get(&node.0).copied() @@ -623,7 +700,7 @@ fn fingerprint( baseline_shift_px, fit_contain, viewport, - align_top, + align, object_fit, } => { mixer.mix(&[1]); @@ -641,7 +718,7 @@ fn fingerprint( mixer.mix(&layout_style.raw().to_le_bytes()); mixer.mix(&baseline_shift_px.to_bits().to_le_bytes()); mixer.mix(&[u8::from(*fit_contain)]); - mixer.mix(&[u8::from(*align_top)]); + mix_atom_align(mixer, align); mixer.mix(&[*object_fit as u8]); } InlineItem::InlineBlock { @@ -649,12 +726,14 @@ fn fingerprint( style, layout_style, baseline_shift_px, + align, } => { mixer.mix(&[4]); mixer.mix(&node.0.to_le_bytes()); mixer.mix(&style.raw().to_le_bytes()); mixer.mix(&layout_style.raw().to_le_bytes()); mixer.mix(&baseline_shift_px.to_bits().to_le_bytes()); + mix_atom_align(mixer, align); } InlineItem::EmptyBox { style, @@ -664,6 +743,14 @@ fn fingerprint( mixer.mix(&style.raw().to_le_bytes()); mixer.mix(&baseline_shift_px.to_bits().to_le_bytes()); } + InlineItem::OpenTag { style } => { + mixer.mix(&[7]); + mixer.mix(&style.raw().to_le_bytes()); + } + InlineItem::CloseTag { style } => { + mixer.mix(&[8]); + mixer.mix(&style.raw().to_le_bytes()); + } } } for item in items { @@ -686,6 +773,17 @@ fn fingerprint( mixer.finish() } +fn mix_atom_align(mixer: &mut FnvMixer, align: &AtomAlign) { + match align { + AtomAlign::Baseline => mixer.mix(&[0]), + AtomAlign::Top => mixer.mix(&[1]), + AtomAlign::Middle { parent } => { + mixer.mix(&[2]); + mixer.mix(&parent.raw().to_le_bytes()); + } + } +} + /// FNV-1a 64 usable both for raw canonical bytes and as a `std::hash::Hasher` /// bridge for `Hash` types, with every integer write pinned little-endian /// and `usize` widened to eight bytes so identical values hash identically @@ -806,6 +904,7 @@ mod tests { x, face: crate::FontFaceId::default(), joins: false, + continues_ligature: false, }; let close = |actual: &[f64], expected: &[f64]| { assert_eq!(actual.len(), expected.len(), "{actual:?} vs {expected:?}"); @@ -922,8 +1021,8 @@ mod tests { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LengthPercentageOrAuto, ListMarkerStyle, - MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, PhysicalSides, Position, - PreferredSize, RubyAlign, + MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, PreferredSize, + RubyAlign, }; fn zero_padding() -> rito_style_contract::NonNegativeLengthPercentage { @@ -953,6 +1052,9 @@ mod tests { bottom: zero_padding(), left: zero_padding(), }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -961,8 +1063,11 @@ mod tests { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, diff --git a/crates/rito-fragment/src/fragment.rs b/crates/rito-fragment/src/fragment.rs index 18ca0385c..cd0278ad2 100644 --- a/crates/rito-fragment/src/fragment.rs +++ b/crates/rito-fragment/src/fragment.rs @@ -23,6 +23,60 @@ pub struct BoxFragment { pub rect: FragmentRect, /// Child fragments in paint order. pub children: Vec, + /// The block edges a fragmentainer break cut this fragment at. + pub cuts: BlockCuts, +} + +/// The block edges of a box fragment that a fragmentainer break cut +/// through. A cut edge belongs to the box's neighbouring fragment: this +/// fragment carries no border, padding or rounded corner there +/// (`box-decoration-break: slice`, Blink's `SetSidesToInclude`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct BlockCuts { + /// The box began in an earlier fragmentainer. + pub start: bool, + /// The box continues in a later fragmentainer. + pub end: bool, +} + +impl BlockCuts { + /// A fragment holding both of its box's block edges. + pub const NONE: Self = Self { + start: false, + end: false, + }; +} + +/// The faces one line's glyph runs were shaped with. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LineFonts { + /// No glyph run (an empty line, or atoms only). + None, + /// Every run used this one face. + One(FontFaceId), + /// Runs used more than one face. + Several, +} + +impl LineFonts { + /// The set with `face` joined. + #[must_use] + pub fn with(self, face: FontFaceId) -> Self { + match self { + Self::None => Self::One(face), + Self::One(one) if one == face => self, + _ => Self::Several, + } + } + + /// Whether the line used any face other than `face`. + pub fn has_other_than(self, face: FontFaceId) -> bool { + match self { + Self::None => false, + Self::One(one) => one != face, + Self::Several => true, + } + } } /// One line box produced by an inline formatting context. @@ -44,11 +98,37 @@ pub struct LineFragment { /// the visible ink extent subtract this from the rect width, matching /// how CSS hangs whitespace at the end of a line. pub trailing_whitespace: f64, - /// The ruby-annotation reserve folded into `baseline`. The paint - /// snap treats this share separately — the annotation reserves whole - /// device rows (ceil) while the strut part keeps the two-stage - /// round. Zero on lines without ruby growth. - pub ruby_growth: f64, + /// The reserve the line's over-side annotations (ruby, emphasis + /// marks) fold into `baseline`: the line box grows at its top and its + /// content moves down by as much. The paint snap treats this share + /// separately — the annotation reserves whole device rows (ceil) while + /// the strut part keeps the two-stage round. Zero on lines without + /// annotation growth. + pub annotation_growth: f64, + /// How far the line's over-side annotations reach above its box: the + /// growth the line takes when nothing above it lends them space, as + /// when it opens a fragment of a resumed flow (Blink's block-start + /// annotation space is zero there). `annotation_growth` is this less + /// what the space above absorbs. Zero on lines without annotations. + pub annotation_overflow: f64, + /// On a flow's first line, how much of its overflow the leading under + /// the preceding block's last line lends it + /// (`ConstraintSpace::preceding_line`); zero on every other line. The + /// line is laid out at its whole overflow either way, so the flow's + /// geometry does not depend on what precedes it; the block context + /// spends this with the rest of the space above when the line opens + /// the flow. + pub annotation_lent: f64, + /// Blink's block-end annotation space of the line: how far its box + /// reaches below the em boxes of its text (an annotation on the next + /// line may reuse that much) when positive; when negative, how far its + /// own under-side annotations (emphasis marks) reach past the line's + /// natural bottom, which the box was grown by. + pub annotation_space_end: f64, + /// The fonts the line's glyph runs were shaped with, which the next + /// line's annotations read to know how much of the space under this + /// line's text they may reuse. + pub fonts: LineFonts, /// The flow-text byte range of each glyph run on the line, in visual /// order, with an empty range for each atomic inline (image or inline /// block) at its place among them: the text the line's text fragments @@ -59,6 +139,31 @@ pub struct LineFragment { pub children: Vec, } +impl LineFragment { + /// The space a line with no glyph run lends the annotations over the + /// line after it, in a flow whose font is `font_size` (Blink's + /// `ComputeAnnotationOverflow`, block-end space): its content reaches + /// from the baseline and each atomic inline's top down to the baseline + /// and each atomic inline's own block size from the line's top, and + /// what lies below that is lent; content shorter than the font size + /// counts as an empty line's, the font size centred in the box (see + /// [`empty_line_half_leading`](crate::empty_line_half_leading)). + pub fn glyphless_end_space(&self, font_size: f64) -> f64 { + let (mut over, mut under) = (self.baseline, self.baseline); + for child in &self.children { + if let Fragment::Image(_) | Fragment::Box(_) = child { + let rect = child.rect(); + over = over.min(rect.y); + under = under.max(rect.height); + } + } + if under - over < font_size { + return crate::empty_line_half_leading(self.rect.height, font_size); + } + (self.rect.height - under).max(0.0) + } +} + /// An outside list-item marker: a filled disc whose geometry Blink derives /// from the list item's primary font (measured 2026-07-28, two faces and /// four sizes): diameter = ascent / 3, horizontally the disc's right edge @@ -143,6 +248,66 @@ pub struct TextFragment { /// browser; the anchors it was laid out from stay with it. `None`: one /// segment, from `rect.x`. pub segments: Option>, + /// How each cluster stands, in cluster order, when the line is + /// vertical; `None` on a horizontal line. + pub orientations: Option>, +} + +/// How one cluster of a vertical line stands, and where its glyphs lie +/// from the point the line puts the cluster at: its place down the line, +/// on the run's baseline. Distances across the line run towards the +/// line-under side. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ClusterOrientation { + /// Upright: the glyph's centre line lies `center` across from the + /// run's baseline, its horizontal origin `half_advance` (half its + /// horizontal advance) further and `vertical_origin` (its face's + /// vertical origin) down the line. + Upright { + center: f64, + half_advance: f64, + vertical_origin: f64, + }, + /// Sideways: set as horizontal text turned a quarter clockwise, its + /// alphabetic baseline `baseline` across from the run's baseline. + Sideways { baseline: f64 }, + /// Combined upright (`text-combine-upright: all`): the clusters of one + /// text item set as one horizontal string within an em of the line. + /// The string starts `across` from the run's baseline and its + /// alphabetic baseline lies `down` the line from the unit's place, + /// drawn in `forms` and, when even those overflow the unit, scaled + /// `scale_x` wide about its start. Every cluster of the unit carries + /// the same values; all but the first `continue` it. + Combined { + across: f64, + down: f64, + scale_x: f64, + forms: CombinedForms, + continues: bool, + }, +} + +/// The glyph forms a combined upright string is drawn in: its face's own, +/// or the OpenType half-, third- or quarter-width forms (`hwid`, `twid`, +/// `qwid`) the browser compresses an overlong string into. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CombinedForms { + Proportional, + Half, + Third, + Quarter, +} + +impl CombinedForms { + /// The OpenType feature that selects the forms, if any. + pub fn feature(self) -> Option<&'static str> { + match self { + CombinedForms::Proportional => None, + CombinedForms::Half => Some("hwid"), + CombinedForms::Third => Some("twid"), + CombinedForms::Quarter => Some("qwid"), + } + } } /// A stretch of a text fragment's clusters placed from one origin. @@ -265,6 +430,10 @@ pub struct ClusterPosition { /// or kerning added, no glyph offset) in the same face, under the /// float law. Such a cluster needs no origin of its own. pub joins: bool, + /// Whether the cluster continues a ligature: it has no glyph of its + /// own, the ligature's start draws it, and a pen draws the two in one + /// string. + pub continues_ligature: bool, } /// One laid-out atomic inline (an image) inside a line. @@ -311,6 +480,36 @@ impl Fragment { Self::Image(fragment) => fragment.rect, } } + + /// The border-box rectangle, to move or resize in place. + pub fn rect_mut(&mut self) -> &mut FragmentRect { + match self { + Self::Box(fragment) => &mut fragment.rect, + Self::Line(fragment) => &mut fragment.rect, + Self::Text(fragment) => &mut fragment.rect, + Self::Image(fragment) => &mut fragment.rect, + } + } + + /// The tops of the first and the last line box in this fragment's flow + /// (itself when it is one), relative to its own top, or `None` when it + /// holds none. + pub fn line_top_span(&self) -> Option<(f64, f64)> { + match self { + Self::Line(_) => Some((0.0, 0.0)), + Self::Box(fragment) => fragment + .children + .iter() + .filter_map(|child| { + let top = child.rect().y; + child + .line_top_span() + .map(|(first, last)| (top + first, top + last)) + }) + .reduce(|(first, last), (start, end)| (first.min(start), last.max(end))), + Self::Text(_) | Self::Image(_) => None, + } + } } /// The immutable output of one formatting-context invocation: the fragments diff --git a/crates/rito-fragment/src/lib.rs b/crates/rito-fragment/src/lib.rs index 8a9bcd2e2..5887264f9 100644 --- a/crates/rito-fragment/src/lib.rs +++ b/crates/rito-fragment/src/lib.rs @@ -19,24 +19,29 @@ mod context; mod formatting_tree; mod fragment; mod serialize; +mod writing; pub use break_token::{BreakToken, BreakTokenStage, FloatBreak}; pub use cache::{CachedLayout, FragmentCache}; -pub use constraint_space::{ConstraintSpace, FloatBand, LineText}; +pub use constraint_space::{ + empty_line_half_leading, ConstraintSpace, FloatBand, LineText, PrecedingLine, +}; pub use context::{ - CancelFlag, EscapedFloat, FormattingContext, IntrinsicInlineSizes, LayoutError, LayoutOutcome, - PendingPositioned, + AtomicBlockLayout, AtomicBlockSizes, BlockLayoutDelegate, CancelFlag, EscapedFloat, + FormattingContext, IntrinsicInlineSizes, LayoutError, LayoutOutcome, PendingPositioned, }; pub use formatting_tree::{ allocate_ruby_annotation, allocate_ruby_annotation_range, distribute_ruby_annotation, - FormattingNode, FormattingNodeContent, FormattingNodeId, FormattingTree, FormattingTreeStyles, - InlineItem, RubyAnnotation, + AtomAlign, FormattingNode, FormattingNodeContent, FormattingNodeId, FormattingTree, + FormattingTreeStyles, InlineItem, RubyAnnotation, }; pub use fragment::{ - BoxFragment, BoxSnap, ClusterPosition, FontFaceId, Fragment, FragmentRect, FragmentTree, - ImageFragment, LineFragment, MarkerFragment, TextFragment, TextSegment, + BlockCuts, BoxFragment, BoxSnap, ClusterOrientation, ClusterPosition, CombinedForms, + FontFaceId, Fragment, FragmentRect, FragmentTree, ImageFragment, LineFonts, LineFragment, + MarkerFragment, TextFragment, TextSegment, }; pub use serialize::{decode_layout_outcome, encode_layout_outcome}; +pub use writing::WritingModeConverter; /// The one stub provider this crate ships: bounded vertical block stacking /// with fragmentainer breaks, enough for the substrate's own contract tests. diff --git a/crates/rito-fragment/src/serialize.rs b/crates/rito-fragment/src/serialize.rs index 8848d9db2..482e0ba89 100644 --- a/crates/rito-fragment/src/serialize.rs +++ b/crates/rito-fragment/src/serialize.rs @@ -10,7 +10,8 @@ use crate::break_token::{BreakToken, BreakTokenStage}; use crate::context::LayoutOutcome; use crate::formatting_tree::FormattingNodeId; use crate::fragment::{ - BoxFragment, Fragment, FragmentRect, FragmentTree, ImageFragment, LineFragment, TextFragment, + BlockCuts, BoxFragment, ClusterOrientation, CombinedForms, Fragment, FragmentRect, + FragmentTree, ImageFragment, LineFonts, LineFragment, TextFragment, }; const FRAGMENT_TAG_BOX: u8 = 0; @@ -67,6 +68,7 @@ fn encode_fragment(fragment: &Fragment, out: &mut Vec) { out.push(FRAGMENT_TAG_BOX); out.extend_from_slice(&fragment.source.0.to_le_bytes()); encode_rect(&fragment.rect, out); + out.push(u8::from(fragment.cuts.start) | (u8::from(fragment.cuts.end) << 1)); out.extend_from_slice(&(fragment.children.len() as u32).to_le_bytes()); for child in &fragment.children { encode_fragment(child, out); @@ -78,7 +80,18 @@ fn encode_fragment(fragment: &Fragment, out: &mut Vec) { encode_rect(&fragment.rect, out); out.extend_from_slice(&fragment.baseline.to_bits().to_le_bytes()); out.extend_from_slice(&fragment.trailing_whitespace.to_bits().to_le_bytes()); - out.extend_from_slice(&fragment.ruby_growth.to_bits().to_le_bytes()); + out.extend_from_slice(&fragment.annotation_growth.to_bits().to_le_bytes()); + out.extend_from_slice(&fragment.annotation_overflow.to_bits().to_le_bytes()); + out.extend_from_slice(&fragment.annotation_lent.to_bits().to_le_bytes()); + out.extend_from_slice(&fragment.annotation_space_end.to_bits().to_le_bytes()); + match fragment.fonts { + LineFonts::None => out.push(0), + LineFonts::One(face) => { + out.push(1); + out.extend_from_slice(&face.0.to_le_bytes()); + } + LineFonts::Several => out.push(2), + } match &fragment.marker { None => out.push(0), Some(marker) => { @@ -137,7 +150,7 @@ fn encode_fragment(fragment: &Fragment, out: &mut Vec) { out.extend_from_slice(&cluster.byte.to_le_bytes()); out.extend_from_slice(&cluster.x.to_bits().to_le_bytes()); out.extend_from_slice(&cluster.face.0.to_le_bytes()); - out.push(u8::from(cluster.joins)); + out.push(u8::from(cluster.joins) | (u8::from(cluster.continues_ligature) << 1)); } out.push(u8::from(fragment.cluster_grid)); match &fragment.segments { @@ -151,6 +164,50 @@ fn encode_fragment(fragment: &Fragment, out: &mut Vec) { } } } + match &fragment.orientations { + None => out.push(0), + Some(orientations) => { + out.push(1); + out.extend_from_slice(&(orientations.len() as u32).to_le_bytes()); + for orientation in orientations.iter() { + match orientation { + ClusterOrientation::Upright { + center, + half_advance, + vertical_origin, + } => { + out.push(0); + for value in [center, half_advance, vertical_origin] { + out.extend_from_slice(&value.to_bits().to_le_bytes()); + } + } + ClusterOrientation::Sideways { baseline } => { + out.push(1); + out.extend_from_slice(&baseline.to_bits().to_le_bytes()); + } + ClusterOrientation::Combined { + across, + down, + scale_x, + forms, + continues, + } => { + out.push(2); + for value in [across, down, scale_x] { + out.extend_from_slice(&value.to_bits().to_le_bytes()); + } + out.push(match forms { + CombinedForms::Proportional => 0, + CombinedForms::Half => 1, + CombinedForms::Third => 2, + CombinedForms::Quarter => 3, + }); + out.push(u8::from(*continues)); + } + } + } + } + } } Fragment::Image(fragment) => { out.push(FRAGMENT_TAG_IMAGE); @@ -173,6 +230,13 @@ fn decode_fragment(reader: &mut Reader<'_>) -> Result { let rect = decode_rect(reader)?; match tag { FRAGMENT_TAG_BOX => { + let cuts = match reader.u8()? { + bits @ 0..=3 => BlockCuts { + start: bits & 1 != 0, + end: bits & 2 != 0, + }, + bits => return Err(format!("unknown box cut bits {bits}")), + }; let child_count = reader.u32()? as usize; let mut children = Vec::with_capacity(child_count.min(reader.remaining())); for _ in 0..child_count { @@ -182,12 +246,22 @@ fn decode_fragment(reader: &mut Reader<'_>) -> Result { source, rect, children, + cuts, })) } FRAGMENT_TAG_LINE => { let baseline = reader.f64()?; let trailing_whitespace = reader.f64()?; - let ruby_growth = reader.f64()?; + let annotation_growth = reader.f64()?; + let annotation_overflow = reader.f64()?; + let annotation_lent = reader.f64()?; + let annotation_space_end = reader.f64()?; + let fonts = match reader.u8()? { + 0 => LineFonts::None, + 1 => LineFonts::One(crate::FontFaceId(reader.u32()?)), + 2 => LineFonts::Several, + tag => return Err(format!("unknown line fonts tag {tag}")), + }; let marker = match reader.u8()? { 0 => None, 1 => Some(crate::MarkerFragment { @@ -212,7 +286,11 @@ fn decode_fragment(reader: &mut Reader<'_>) -> Result { rect, baseline, trailing_whitespace, - ruby_growth, + annotation_growth, + annotation_overflow, + annotation_lent, + annotation_space_end, + fonts, marker, text_runs, children, @@ -245,15 +323,19 @@ fn decode_fragment(reader: &mut Reader<'_>) -> Result { let cluster_count = reader.u32()? as usize; let mut clusters = Vec::with_capacity(cluster_count.min(4096)); for _ in 0..cluster_count { + let byte = reader.u32()?; + let x = reader.f64()?; + let face = crate::FontFaceId(reader.u32()?); + let flags = reader.u8()?; + if flags > 3 { + return Err(format!("unknown cluster joins tag {flags}")); + } clusters.push(crate::ClusterPosition { - byte: reader.u32()?, - x: reader.f64()?, - face: crate::FontFaceId(reader.u32()?), - joins: match reader.u8()? { - 0 => false, - 1 => true, - tag => return Err(format!("unknown cluster joins tag {tag}")), - }, + byte, + x, + face, + joins: flags & 1 != 0, + continues_ligature: flags & 2 != 0, }); } let cluster_grid = match reader.u8()? { @@ -276,6 +358,41 @@ fn decode_fragment(reader: &mut Reader<'_>) -> Result { } tag => return Err(format!("unknown text segments tag {tag}")), }; + let orientations = match reader.u8()? { + 0 => None, + 1 => { + let count = reader.u32()? as usize; + let mut orientations = Vec::with_capacity(count.min(4096)); + for _ in 0..count { + orientations.push(match reader.u8()? { + 0 => ClusterOrientation::Upright { + center: reader.f64()?, + half_advance: reader.f64()?, + vertical_origin: reader.f64()?, + }, + 1 => ClusterOrientation::Sideways { + baseline: reader.f64()?, + }, + 2 => ClusterOrientation::Combined { + across: reader.f64()?, + down: reader.f64()?, + scale_x: reader.f64()?, + forms: match reader.u8()? { + 0 => CombinedForms::Proportional, + 1 => CombinedForms::Half, + 2 => CombinedForms::Third, + 3 => CombinedForms::Quarter, + tag => return Err(format!("unknown combined forms tag {tag}")), + }, + continues: reader.u8()? != 0, + }, + tag => return Err(format!("unknown cluster orientation tag {tag}")), + }); + } + Some(orientations.into()) + } + tag => return Err(format!("unknown text orientations tag {tag}")), + }; Ok(Fragment::Text(TextFragment { source, rect, @@ -292,6 +409,7 @@ fn decode_fragment(reader: &mut Reader<'_>) -> Result { clusters: clusters.into(), cluster_grid, segments, + orientations, })) } FRAGMENT_TAG_IMAGE => { @@ -402,10 +520,12 @@ impl Reader<'_> { Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + fn f64(&mut self) -> Result { - Ok(f64::from_bits(u64::from_le_bytes( - self.take(8)?.try_into().unwrap(), - ))) + Ok(f64::from_bits(self.u64()?)) } } @@ -436,6 +556,7 @@ mod tests { height: 120.5, }, children: Vec::new(), + cuts: BlockCuts::NONE, }), Fragment::Box(BoxFragment { source: FormattingNodeId(2), @@ -446,8 +567,13 @@ mod tests { height: 79.5, }, children: Vec::new(), + cuts: BlockCuts { + start: false, + end: true, + }, }), ], + cuts: BlockCuts::NONE, }), }, continuation: Some(BreakToken { @@ -529,7 +655,11 @@ mod tests { }, baseline: 14.8, trailing_whitespace: 4.25, - ruby_growth: 1.5, + annotation_growth: 1.5, + annotation_overflow: 2.0, + annotation_lent: 0.5, + annotation_space_end: -0.25, + fonts: LineFonts::One(crate::FontFaceId(7)), text_runs: vec![(0, 42)], children: vec![Fragment::Text(TextFragment { source: FormattingNodeId(0), @@ -561,8 +691,27 @@ mod tests { opener_trim_px: 0.0, ruby_overhang_px: 0.75, ruby_overhang_right_px: 0.75, + orientations: Some( + vec![ + crate::ClusterOrientation::Upright { + center: -1.0, + half_advance: 9.0, + vertical_origin: 15.84, + }, + crate::ClusterOrientation::Sideways { baseline: 5.0 }, + crate::ClusterOrientation::Combined { + across: 4.5, + down: 14.5, + scale_x: 0.5, + forms: crate::CombinedForms::Half, + continues: true, + }, + ] + .into(), + ), })], })], + cuts: BlockCuts::NONE, }), }, continuation: None, diff --git a/crates/rito-fragment/src/writing.rs b/crates/rito-fragment/src/writing.rs new file mode 100644 index 000000000..30bfcbb12 --- /dev/null +++ b/crates/rito-fragment/src/writing.rs @@ -0,0 +1,210 @@ +//! Between a container's flow-relative coordinates and physical ones. +//! +//! Layout places a box's children in the box's own writing mode: a +//! child's [`FragmentRect`] is `x` along the lines from the inline-start +//! edge, `y` across them from the block-start edge, `width` its inline +//! extent and `height` its block extent. What paints or hit-tests needs +//! physical rectangles, relative to the container's top-left corner; +//! [`WritingModeConverter`] turns one into the other for a container of a +//! known physical size, as Blink's `WritingModeConverter` does. + +use rito_style_contract::{WritingDirection, WritingMode}; + +use crate::FragmentRect; + +/// Converts rectangles between a container's flow-relative coordinates +/// and physical coordinates relative to its top-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WritingModeConverter { + writing: WritingDirection, + outer_width: f64, + outer_height: f64, +} + +impl WritingModeConverter { + /// For a container laid out in `writing` whose physical size is + /// `outer_width` × `outer_height`. + pub fn new(writing: WritingDirection, outer_width: f64, outer_height: f64) -> Self { + Self { + writing, + outer_width, + outer_height, + } + } + + /// For a container laid out in `writing` whose flow-relative size is + /// `inline_size` × `block_size`. + pub fn from_logical_size(writing: WritingDirection, inline_size: f64, block_size: f64) -> Self { + let (outer_width, outer_height) = if writing.is_horizontal() { + (inline_size, block_size) + } else { + (block_size, inline_size) + }; + Self::new(writing, outer_width, outer_height) + } + + /// The writing mode and direction converted from. + pub fn writing(&self) -> WritingDirection { + self.writing + } + + /// The container's physical width and height. + pub fn outer_size(&self) -> (f64, f64) { + (self.outer_width, self.outer_height) + } + + /// A child's flow-relative rectangle as a physical one. + pub fn to_physical(&self, rect: FragmentRect) -> FragmentRect { + if self.writing == WritingDirection::HORIZONTAL_LTR { + return rect; + } + let rtl = self.writing.is_rtl(); + match self.writing.mode { + WritingMode::HorizontalTopToBottom => FragmentRect { + x: self.outer_width - rect.x - rect.width, + ..rect + }, + WritingMode::VerticalRightToLeft | WritingMode::VerticalLeftToRight => { + let x = if self.writing.is_flipped_blocks() { + self.outer_width - rect.y - rect.height + } else { + rect.y + }; + let y = if rtl { + self.outer_height - rect.x - rect.width + } else { + rect.x + }; + FragmentRect { + x, + y, + width: rect.height, + height: rect.width, + } + } + } + } + + /// A physical rectangle as a child's flow-relative one: the inverse of + /// [`Self::to_physical`]. + pub fn to_logical(&self, rect: FragmentRect) -> FragmentRect { + if self.writing == WritingDirection::HORIZONTAL_LTR { + return rect; + } + let rtl = self.writing.is_rtl(); + match self.writing.mode { + WritingMode::HorizontalTopToBottom => FragmentRect { + x: self.outer_width - rect.x - rect.width, + ..rect + }, + WritingMode::VerticalRightToLeft | WritingMode::VerticalLeftToRight => { + let block = if self.writing.is_flipped_blocks() { + self.outer_width - rect.x - rect.width + } else { + rect.x + }; + let inline = if rtl { + self.outer_height - rect.y - rect.height + } else { + rect.y + }; + FragmentRect { + x: inline, + y: block, + width: rect.height, + height: rect.width, + } + } + } + } +} + +#[cfg(test)] +mod tests { + use rito_style_contract::Direction; + + use super::*; + + fn rect(x: f64, y: f64, width: f64, height: f64) -> FragmentRect { + FragmentRect { + x, + y, + width, + height, + } + } + + fn writing(mode: WritingMode, direction: Direction) -> WritingDirection { + WritingDirection { mode, direction } + } + + #[test] + fn a_vertical_rl_child_sits_in_from_the_right_edge() { + // A 600 wide, 800 tall vertical-rl container: a child 30 into the + // block axis and 40 down the inline axis, 20 thick and 100 long. + let converter = WritingModeConverter::new( + writing(WritingMode::VerticalRightToLeft, Direction::LeftToRight), + 600.0, + 800.0, + ); + let child = rect(40.0, 30.0, 100.0, 20.0); + let physical = converter.to_physical(child); + assert_eq!(physical, rect(550.0, 40.0, 20.0, 100.0)); + assert_eq!(converter.to_logical(physical), child); + } + + #[test] + fn each_writing_direction_converts_back_to_where_it_started() { + let child = rect(40.0, 30.0, 100.0, 20.0); + let cases = [ + ( + WritingMode::HorizontalTopToBottom, + Direction::LeftToRight, + rect(40.0, 30.0, 100.0, 20.0), + ), + ( + WritingMode::HorizontalTopToBottom, + Direction::RightToLeft, + rect(460.0, 30.0, 100.0, 20.0), + ), + ( + WritingMode::VerticalRightToLeft, + Direction::RightToLeft, + rect(550.0, 660.0, 20.0, 100.0), + ), + ( + WritingMode::VerticalLeftToRight, + Direction::LeftToRight, + rect(30.0, 40.0, 20.0, 100.0), + ), + ( + WritingMode::VerticalLeftToRight, + Direction::RightToLeft, + rect(30.0, 660.0, 20.0, 100.0), + ), + ]; + for (mode, direction, expected) in cases { + let converter = WritingModeConverter::new(writing(mode, direction), 600.0, 800.0); + assert_eq!( + converter.to_physical(child), + expected, + "{mode:?} {direction:?}" + ); + assert_eq!( + converter.to_logical(expected), + child, + "{mode:?} {direction:?}" + ); + } + } + + #[test] + fn a_logical_size_names_the_physical_axes_its_mode_puts_it_on() { + let vertical = WritingModeConverter::from_logical_size( + writing(WritingMode::VerticalRightToLeft, Direction::LeftToRight), + 800.0, + 600.0, + ); + assert_eq!(vertical.outer_size(), (600.0, 800.0)); + } +} diff --git a/crates/rito-fragment/tests/line_end_space.rs b/crates/rito-fragment/tests/line_end_space.rs new file mode 100644 index 000000000..5ba2473c5 --- /dev/null +++ b/crates/rito-fragment/tests/line_end_space.rs @@ -0,0 +1,61 @@ +//! What a line with no glyph run lends the annotations over the next line. + +use rito_fragment::{ + empty_line_half_leading, FormattingNodeId, Fragment, FragmentRect, ImageFragment, LineFonts, + LineFragment, +}; + +fn line(height: f64, baseline: f64, children: Vec) -> LineFragment { + LineFragment { + source: FormattingNodeId(0), + rect: FragmentRect { + x: 0.0, + y: 0.0, + width: 300.0, + height, + }, + baseline, + marker: None, + trailing_whitespace: 0.0, + annotation_growth: 0.0, + annotation_overflow: 0.0, + annotation_lent: 0.0, + annotation_space_end: 0.0, + fonts: LineFonts::None, + text_runs: Vec::new(), + children, + } +} + +fn image(top: f64, height: f64) -> Fragment { + Fragment::Image(ImageFragment { + source: FormattingNodeId(0), + rect: FragmentRect { + x: 0.0, + y: top, + width: 200.0, + height, + }, + item_index: 0, + }) +} + +/// An empty line lends its half-leading; a line holding an image on its +/// baseline lends only what lies under the image (measured: a ruby line +/// after a paragraph of one baseline-aligned illustration sits where the +/// image line's space under its baseline alone is reused). +#[test] +fn a_line_holding_an_image_lends_only_the_space_under_it() { + let empty = line(28.0, 21.0, Vec::new()); + assert_eq!( + empty.glyphless_end_space(16.0), + empty_line_half_leading(28.0, 16.0) + ); + assert_eq!(empty.glyphless_end_space(16.0), 6.0); + // A 400px image standing on the baseline of a 414px line box. + let illustrated = line(414.0, 400.0, vec![image(0.0, 400.0)]); + assert_eq!(illustrated.glyphless_end_space(16.0), 14.0); + // An image shorter than the font counts as empty content. + let small = line(28.0, 21.0, vec![image(11.0, 10.0)]); + assert_eq!(small.glyphless_end_space(16.0), 6.0); +} diff --git a/crates/rito-inline/Cargo.toml b/crates/rito-inline/Cargo.toml index de57da8bf..51dcd5c23 100644 --- a/crates/rito-inline/Cargo.toml +++ b/crates/rito-inline/Cargo.toml @@ -14,6 +14,9 @@ parley = { version = "0.11", default-features = false, features = ["std"] } # Font table introspection: line metrics and the Han-kerning `halt` gate. Already in the # graph through parley, so this adds no weight to the wasm binary. skrifa = "0.43" +# Unicode's vertical orientation property, which decides the characters a +# mixed vertical line sets upright. Already in the graph through parley. +icu_properties = { version = "2.1.2", default-features = false, features = ["compiled_data"] } # A fast non-cryptographic hasher for the per-layout caches; already in the # graph through parley. rustc-hash = "2" diff --git a/crates/rito-inline/examples/break_matrix.rs b/crates/rito-inline/examples/break_matrix.rs new file mode 100644 index 000000000..84bccffd5 --- /dev/null +++ b/crates/rito-inline/examples/break_matrix.rs @@ -0,0 +1,167 @@ +//! Diagnostic: the engine's verdict on every ordered pair of a character +//! set — may a line break between A and B — for each content language and +//! `line-break` value, in the shape of the browser probe's output so the +//! two compare line for line. Each case is a paragraph +//! "一二三四五六七" + A + B + "九九" in the pinned serif at 16px whose +//! width is the engine's own width of "一二三四五六七" + A plus 0.5px: the +//! first line ends exactly after A iff the pair is a break opportunity. +//! +//! usage: cargo run --release -p rito-inline --example break_matrix -- +//! [langs] [line-breaks] [word-break] +use std::collections::HashMap; +use std::fmt::Write as _; + +use rito_fragment::{ + CancelFlag, ConstraintSpace, FormattingContext, FormattingNode, FormattingNodeContent, + FormattingNodeId, FormattingTree, Fragment, InlineItem, +}; +use rito_inline::{plain_paragraph_style, ParleyInlineContext}; +use rito_style_contract::{ + FontFamilies, FontFamily, FontFamilyName, InlineStyleTable, LanguageTag, LayoutStyleTable, + LineBreak, WordBreak, +}; + +const PREFIX: &str = "一二三四五六七"; + +fn main() { + let args: Vec = std::env::args().collect(); + let [_, font, chars, out, ..] = args.as_slice() else { + panic!("usage: break_matrix [langs] [line-breaks]"); + }; + let langs = args + .get(4) + .map_or("ja,zh-CN,zh-TW,ko,en,none", String::as_str); + let line_breaks = args + .get(5) + .map_or("auto,normal,loose,strict", String::as_str); + let word_break = args.get(6).map_or("normal", String::as_str); + let context = ParleyInlineContext::new(vec![std::fs::read(font).expect("font reads")]) + .expect("context builds"); + let chars: Vec = std::fs::read_to_string(chars) + .expect("chars read") + .split_whitespace() + .map(|hex| char::from_u32(u32::from_str_radix(hex, 16).expect("hex")).expect("scalar")) + .collect(); + let mut rows = String::new(); + for lang in langs.split(',') { + for line_break in line_breaks.split(',') { + let mut widths: HashMap = HashMap::new(); + let mut breakable = 0; + for &a in &chars { + let width = *widths.entry(a).or_insert_with(|| { + first_line( + &context, + (lang, line_break, word_break), + &format!("{PREFIX}{a}"), + 1.0e6, + ) + .1 + }); + for &b in &chars { + let text = format!("{PREFIX}{a}{b}九九"); + let (end, _) = + first_line(&context, (lang, line_break, word_break), &text, width + 0.5); + let chars_on_line = text[..end].chars().count(); + let verdict = usize::from(chars_on_line == PREFIX.chars().count() + 1); + breakable += verdict; + let config = if word_break == "normal" { + line_break.to_owned() + } else { + format!("{line_break}/{word_break}") + }; + writeln!( + rows, + "{lang}\t{config}\t{:x}\t{:x}\t{verdict}\t{chars_on_line}", + u32::from(a), + u32::from(b) + ) + .expect("row writes"); + } + } + eprintln!( + "{lang} {line_break}: {breakable}/{} breakable", + chars.len() * chars.len() + ); + } + } + std::fs::write(out, rows).expect("rows write"); +} + +/// The byte where the first line of `text` ends and that line's width, +/// laid out `width` wide under a content language, `line-break` and +/// `word-break`. +fn first_line( + context: &ParleyInlineContext, + (lang, line_break, word_break): (&str, &str, &str), + text: &str, + width: f64, +) -> (usize, f64) { + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Named(FontFamilyName::new("NoSuchFace"))]) + .expect("family list"), + 16.0, + 0.0, + ); + style.text_flow.language = (lang != "none").then(|| LanguageTag::new(lang)); + style.text_flow.line_break = match line_break { + "auto" => LineBreak::Auto, + "normal" => LineBreak::Normal, + "loose" => LineBreak::Loose, + "strict" => LineBreak::Strict, + "anywhere" => LineBreak::Anywhere, + other => panic!("unknown line-break {other}"), + }; + style.text_flow.word_break = match word_break { + "normal" => WordBreak::Normal, + "break-all" => WordBreak::BreakAll, + "keep-all" => WordBreak::KeepAll, + other => panic!("unknown word-break {other}"), + }; + let mut inline = InlineStyleTable::new(1); + let style = inline.intern_for_node(0, style).expect("style interns"); + let nodes = vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }]; + let tree = FormattingTree::with_styles( + nodes, + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(width), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("inline outcome root is a box fragment"); + }; + let Some(Fragment::Line(line)) = root.children.first() else { + panic!("the paragraph has a first line"); + }; + let mut end = 0_u32; + let mut line_width = 0.0; + for child in &line.children { + if let Fragment::Text(run) = child { + end = end.max(run.text_end); + } + line_width += child.rect().width; + } + (end as usize, line_width) +} diff --git a/crates/rito-inline/src/atom_align.rs b/crates/rito-inline/src/atom_align.rs new file mode 100644 index 000000000..684098d17 --- /dev/null +++ b/crates/rito-inline/src/atom_align.rs @@ -0,0 +1,63 @@ +//! `vertical-align: middle` on an atomic inline (an image or an +//! inline-block): where its box sits around the line's baseline. + +use crate::marker::resolved_marker_font_metrics; +use crate::*; + +impl ParleyInlineContext { + /// Per item of the flow at `root`: for an atom aligned `middle`, half + /// its parent box's x-height on the LayoutUnit grid — how far above the + /// parent's baseline the atom's middle sits; `None` for every other + /// item. The x-height is the parent's primary font's, read as the list + /// marker reads it. + pub(crate) fn middle_half_x_heights( + &self, + tree: &FormattingTree, + root: FormattingNodeId, + ) -> Vec> { + let FormattingNodeContent::InlineFlow { items } = &tree.node(root).content else { + return Vec::new(); + }; + let tables = tree.styles(); + items + .iter() + .map(|item| { + let (InlineItem::Image { + align: AtomAlign::Middle { parent }, + .. + } + | InlineItem::InlineBlock { + align: AtomAlign::Middle { parent }, + .. + }) = item + else { + return None; + }; + let style = tables?.inline.style(*parent).ok()?; + let size = f64::from(style.font.size.get()); + let x_height = resolved_marker_font_metrics( + &mut self.fonts.borrow_mut(), + &self.catalog, + style, + ) + .map_or(size * 0.5, |(_, x_height)| x_height); + Some((x_height / 2.0 * 64.0).round() / 64.0) + }) + .collect() + } +} + +/// The raise, CSS px, that `vertical-align: middle` gives an atom standing +/// `above` and `below` its baseline in a parent whose half x-height is +/// `half_x_height`. Blink moves the box down by half its ascent less its +/// descent, a LayoutUnit halved and truncated, less that half x-height +/// (`InlineLayoutStateStack::ApplyBaselineShift`): the box's middle comes +/// to rest half the parent's x-height above the parent's baseline. +/// Measured: a 30px image in a 10px Tinos line spans 17.296875 above the +/// baseline to 12.703125 below; down a vertical-rl line, where an image's +/// central baseline halves it, it moves the half x-height to the line's +/// over side. +pub(crate) fn middle_raise(above: f64, below: f64, half_x_height: f64) -> f64 { + let difference = ((above - below) * 64.0).round() as i64; + half_x_height - (difference / 2) as f64 / 64.0 +} diff --git a/crates/rito-inline/src/atoms.rs b/crates/rito-inline/src/atoms.rs index 261b98773..e13dfc9d1 100644 --- a/crates/rito-inline/src/atoms.rs +++ b/crates/rito-inline/src/atoms.rs @@ -46,30 +46,43 @@ impl ParleyInlineContext { .layout .style(*layout_style) .map_err(|error| LayoutError::Invalid(error.to_string()))?; + let vertical = tree + .strut_style(host) + .and_then(|id| styles.inline.style(id).ok()) + .is_some_and(|strut| { + strut.bidi.writing_mode + == rito_style_contract::WritingMode::VerticalRightToLeft + }); + // The image sizes in physical terms: down a column its + // width runs across the lines (the page's block size) and + // its height along them, so the page clamp holds it to + // the column's length (measured on a 318x2048 chapter + // plate: Chromium lays it 132x850 on an 850px column). + let (width_basis, height_clamp, height_basis) = if vertical { + (space.block_size(), space.inline_size(), space.inline_size()) + } else { + ( + space.inline_size(), + space.block_size(), + space.containing_block_size(), + ) + }; let (width, height) = image_display_size( *intrinsic_width, *intrinsic_height, layout_style, - space.inline_size(), - space.block_size(), - space.containing_block_size(), + width_basis, + height_clamp, + height_basis, percentage_images, *viewport, )?; // A vertical-rl flow's replaced atom advances by its - // PHYSICAL height along the (vertical) inline axis - // and takes a column as wide as its physical width - // (measured on a 318x2048 chapter plate: the column - // is 318 wide, the image runs the page's length and - // clips); the swapped box maps back to the physical - // raster in the vertical paint walk. - let (width, height) = if tree - .strut_style(host) - .and_then(|id| styles.inline.style(id).ok()) - .is_some_and(|strut| { - strut.bidi.writing_mode - == rito_style_contract::WritingMode::VerticalRightToLeft - }) { + // physical height along the (vertical) inline axis and + // takes a column as wide as its physical width; the + // swapped box maps back to the physical raster in the + // vertical paint walk. + let (width, height) = if vertical { (height, width) } else { (width, height) @@ -84,8 +97,19 @@ impl ParleyInlineContext { LengthPercentage::Length(px) => f64::from(px.get()).max(0.0), _ => 0.0, }; - let inset_left = edge(layout_style.padding.left); - let inset_right = edge(layout_style.padding.right); + // The flanks along the line: left and right in a + // horizontal flow, top and bottom down a column. + let (inset_left, inset_right) = if vertical { + ( + edge(layout_style.padding.top), + edge(layout_style.padding.bottom), + ) + } else { + ( + edge(layout_style.padding.left), + edge(layout_style.padding.right), + ) + }; Ok(Some(SizedAtom { width: width + (inset_left + inset_right) as f32, height, @@ -94,45 +118,15 @@ impl ParleyInlineContext { block: None, })) } - InlineItem::InlineBlock { node, .. } => { - // The atomic inline is its own mini paragraph, laid - // out recursively through the full pipeline at CSS - // 2.1 §10.3.5 shrink-to-fit width against the host. - let sizes = self.intrinsic_inline_sizes(tree, *node)?; - let available = space.inline_size().unwrap_or(sizes.max_content); - let width = sizes.max_content.min(sizes.min_content.max(available)); - let outcome = FormattingContext::layout( - self, - tree, - *node, - &ConstraintSpace::continuous(width), - None, - cancel, - )?; - let Fragment::Box(root_box) = outcome.fragments.root else { - return Err(LayoutError::Invalid( - "inline-block layout must produce a box fragment".to_owned(), - )); - }; - // The atom's baseline is its LAST line's baseline - // (CSS §10.8.1); a line-less box uses its bottom. - let baseline = root_box - .children - .iter() - .rev() - .find_map(|child| match child { - Fragment::Line(line) => Some(line.rect.y + line.baseline), - _ => None, - }) - .unwrap_or(root_box.rect.height); - Ok(Some(SizedAtom { - width: root_box.rect.width as f32, - height: root_box.rect.height as f32, - edge_insets: None, - block: Some((node.0, baseline, root_box)), - })) - } - InlineItem::Text { .. } | InlineItem::EmptyBox { .. } => Ok(None), + InlineItem::InlineBlock { + node, layout_style, .. + } => self + .size_inline_block(tree, host, *node, *layout_style, space, cancel) + .map(Some), + InlineItem::Text { .. } + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => Ok(None), } } diff --git a/crates/rito-inline/src/break_opportunities/classes.rs b/crates/rito-inline/src/break_opportunities/classes.rs new file mode 100644 index 000000000..8287693a8 --- /dev/null +++ b/crates/rito-inline/src/break_opportunities/classes.rs @@ -0,0 +1,278 @@ +//! The ICU line rule sets Chromium opens and the line breaking class each +//! character takes under one: UAX #14's classes as ICU's rule files +//! (`line.txt` and its `_cj`, `normal` and `loose` tailorings) resolve +//! and tailor them. + +use icu_properties::props::{ + EastAsianWidth, ExtendedPictographic, GeneralCategory, LineBreak as Lb, +}; +use icu_properties::{CodePointMapData, CodePointSetData}; +use rito_style_contract::LineBreak; + +/// One of ICU's line rule sets (its `brkitr/rules/line*.txt` files). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RuleSet { + /// `line.txt`: CSS `strict`; small kana and the prolonged sound mark + /// are nonstarters. + Strict, + /// `line_normal.txt`: small kana and the prolonged sound mark may + /// start a line. + Normal, + /// `line_normal_cj.txt`: `normal` as Chinese and Japanese define it — + /// the curly double quotes as brackets, and a line may start with 〜 + /// or ゠. + NormalCj, + /// `line_loose.txt`: `normal` that also starts lines with iteration + /// marks and breaks between inseparables. + Loose, + /// `line_loose_cj.txt`: `loose` as Chinese and Japanese define it. + LooseCj, +} + +impl RuleSet { + /// The rule set Chromium's ICU opens for a content language and a + /// `line-break` value: the locale's `line`, `line_normal`, + /// `line_loose` or `line_strict` entry (Blink passes `@lb=` only for + /// a value other than `auto`), Chinese, Japanese and Korean carrying + /// their own tables and every other language the root's. Chromium's + /// ICU data leaves out Korean's strict table (`line_cj`), so Korean + /// `strict` opens the locale's default, as Chromium 156 breaks it. + pub(crate) fn for_content(language: Option<&str>, line_break: LineBreak) -> Self { + let primary = language + .and_then(|tag| tag.split(['-', '_']).next()) + .map(str::to_ascii_lowercase); + match (primary.as_deref(), line_break) { + (Some("zh"), LineBreak::Strict) => Self::Strict, + (Some("zh"), LineBreak::Loose) => Self::LooseCj, + (Some("zh"), _) => Self::NormalCj, + (Some("ja"), LineBreak::Strict) => Self::Strict, + (Some("ko"), LineBreak::Strict) => Self::Normal, + (Some("ja" | "ko"), LineBreak::Normal) => Self::NormalCj, + (Some("ja" | "ko"), LineBreak::Loose) => Self::LooseCj, + (_, LineBreak::Strict) => Self::Strict, + (_, LineBreak::Loose) => Self::Loose, + _ => Self::Normal, + } + } + + pub(super) fn loose(self) -> bool { + matches!(self, Self::Loose | Self::LooseCj) + } + + pub(super) fn loose_cj(self) -> bool { + self == Self::LooseCj + } + + /// Whether the rule set takes `character` out of its class: such a + /// character answers only rules that admit any character. + fn unclasses(self, character: char) -> bool { + const NORMAL_CJ: [char; 2] = ['\u{301C}', '\u{30A0}']; + const ITERATION_MARKS: [char; 6] = [ + '\u{3005}', '\u{303B}', '\u{309D}', '\u{309E}', '\u{30FD}', '\u{30FE}', + ]; + const LOOSE_CJ: [char; 10] = [ + '\u{203C}', '\u{2047}', '\u{2048}', '\u{2049}', '\u{30FB}', '\u{FF1A}', '\u{FF1B}', + '\u{FF65}', '\u{FF01}', '\u{FF1F}', + ]; + match self { + Self::Strict | Self::Normal => false, + Self::NormalCj => NORMAL_CJ.contains(&character), + Self::Loose => ITERATION_MARKS.contains(&character), + Self::LooseCj => { + NORMAL_CJ.contains(&character) + || ITERATION_MARKS.contains(&character) + || LOOSE_CJ.contains(&character) + } + } + } +} + +/// A line breaking class as the rules read it (UAX #14 LB1 resolved). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Class { + Ak, + Al, + Ap, + As, + B2, + Ba, + Bb, + Bk, + Cb, + Cl, + Cm, + Cp, + Cr, + Eb, + Em, + Ex, + Gl, + H2, + H3, + Hh, + Hl, + Hy, + Id, + In, + Is, + Jl, + Jt, + Jv, + Lf, + Nl, + Ns, + Nu, + Op, + Po, + Pr, + Qu, + Ri, + Sp, + Sy, + Vf, + Vi, + Wj, + Zw, + Zwj, + /// Taken out of its class by the rule set. + Unclassed, + /// `line_loose_cj`'s postfix exceptions (`$POX`): still a postfix + /// beside letters and before a number. + PoX, + /// `line_loose_cj`'s prefix exceptions (`$PRX`): still a prefix only + /// after a number. + PrX, +} + +/// What the rules read of one character. +#[derive(Clone, Copy, Debug)] +pub(super) struct Props { + pub(super) class: Class, + /// East Asian Width F, W or H. + pub(super) east_asian: bool, + /// General category Pi. + pub(super) initial_quote: bool, + /// General category Pf. + pub(super) final_quote: bool, + /// Extended_Pictographic and unassigned (LB30b). + pub(super) unassigned_pictograph: bool, + /// Line break class SA: broken by dictionary, not by these rules. + pub(super) complex: bool, +} + +/// The class `character` takes under `rule_set`. +pub(super) fn props(character: char, rule_set: RuleSet) -> Props { + let category = CodePointMapData::::new().get(character); + let raw = CodePointMapData::::new().get(character); + let class = if rule_set.unclasses(character) { + Class::Unclassed + } else { + resolve(character, raw, category, rule_set) + }; + Props { + class, + east_asian: matches!( + CodePointMapData::::new().get(character), + EastAsianWidth::Fullwidth | EastAsianWidth::Wide | EastAsianWidth::Halfwidth + ), + initial_quote: category == GeneralCategory::InitialPunctuation, + final_quote: category == GeneralCategory::FinalPunctuation, + unassigned_pictograph: category == GeneralCategory::Unassigned + && CodePointSetData::new::().contains(character), + complex: raw == Lb::ComplexContext, + } +} + +fn resolve(character: char, raw: Lb, category: GeneralCategory, rule_set: RuleSet) -> Class { + let curly_brackets = matches!(rule_set, RuleSet::NormalCj | RuleSet::LooseCj); + if curly_brackets && character == '\u{201C}' { + return Class::Op; + } + if curly_brackets && character == '\u{201D}' { + return Class::Cl; + } + if rule_set.loose_cj() { + const POX: [char; 10] = [ + '\u{00B0}', '\u{2030}', '\u{2032}', '\u{2033}', '\u{2035}', '\u{2103}', '\u{2109}', + '\u{FE6A}', '\u{FF05}', '\u{FFE0}', + ]; + const PRX: [char; 9] = [ + '\u{00A4}', '\u{00B1}', '\u{20AC}', '\u{2116}', '\u{FE69}', '\u{FF04}', '\u{FFE1}', + '\u{FFE5}', '\u{FFE6}', + ]; + if POX.contains(&character) { + return Class::PoX; + } + if PRX.contains(&character) { + return Class::PrX; + } + } + match raw { + Lb::Aksara => Class::Ak, + Lb::AksaraPrebase => Class::Ap, + Lb::AksaraStart => Class::As, + Lb::BreakBoth => Class::B2, + Lb::BreakAfter => Class::Ba, + Lb::BreakBefore => Class::Bb, + Lb::MandatoryBreak => Class::Bk, + Lb::ContingentBreak => Class::Cb, + Lb::ClosePunctuation => Class::Cl, + Lb::CombiningMark => Class::Cm, + Lb::CloseParenthesis => Class::Cp, + Lb::CarriageReturn => Class::Cr, + Lb::EBase => Class::Eb, + Lb::EModifier => Class::Em, + Lb::Exclamation => Class::Ex, + Lb::Glue => Class::Gl, + Lb::H2 => Class::H2, + Lb::H3 => Class::H3, + Lb::UnambiguousHyphen => Class::Hh, + Lb::HebrewLetter => Class::Hl, + Lb::Hyphen => Class::Hy, + Lb::Ideographic => Class::Id, + Lb::Inseparable => Class::In, + Lb::InfixNumeric => Class::Is, + Lb::JL => Class::Jl, + Lb::JT => Class::Jt, + Lb::JV => Class::Jv, + Lb::LineFeed => Class::Lf, + Lb::NextLine => Class::Nl, + Lb::Nonstarter => Class::Ns, + Lb::Numeric => Class::Nu, + Lb::OpenPunctuation => Class::Op, + Lb::PostfixNumeric => Class::Po, + Lb::PrefixNumeric => Class::Pr, + Lb::Quotation => Class::Qu, + Lb::RegionalIndicator => Class::Ri, + Lb::Space => Class::Sp, + Lb::BreakSymbols => Class::Sy, + Lb::ViramaFinal => Class::Vf, + Lb::Virama => Class::Vi, + Lb::WordJoiner => Class::Wj, + Lb::ZWSpace => Class::Zw, + Lb::ZWJ => Class::Zwj, + // LB1: small kana and the prolonged sound mark start lines only + // outside `strict`. + Lb::ConditionalJapaneseStarter => { + if rule_set == RuleSet::Strict { + Class::Ns + } else { + Class::Id + } + } + // LB1: a dictionary character's marks combine; the rest are + // letters. + Lb::ComplexContext => { + if matches!( + category, + GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark + ) { + Class::Cm + } else { + Class::Al + } + } + // LB1: ambiguous, surrogate and unknown characters are letters. + _ => Class::Al, + } +} diff --git a/crates/rito-inline/src/break_opportunities/mod.rs b/crates/rito-inline/src/break_opportunities/mod.rs new file mode 100644 index 000000000..69e5eec60 --- /dev/null +++ b/crates/rito-inline/src/break_opportunities/mod.rs @@ -0,0 +1,285 @@ +//! Where Chromium may break a line (Blink's `LazyLineBreakIterator:: +//! NextBreakablePosition`): never before a breakable space and always +//! after a run of them, a fixed table between two Latin-1 characters, the +//! hyphen-minus before a digit by what comes before it, the breaks +//! `word-break: break-all` adds and those `keep-all` withholds, and +//! everywhere else the ICU line rules of the rule set the content language +//! and `line-break` value select; `line-break: anywhere` breaks everywhere. +//! A boundary inside dictionary-broken text (Thai, Lao, Khmer, Myanmar) is +//! left to the dictionary. + +mod classes; +mod uax14; +mod word_break; + +use std::sync::OnceLock; + +pub(crate) use classes::RuleSet; +use rito_style_contract::WordBreak; + +/// The first and last characters Blink's fast pair table covers. +const TABLE_FIRST: u32 = 0x21; +const TABLE_LAST: u32 = 0xFF; +const TABLE_SIZE: usize = (TABLE_LAST - TABLE_FIRST + 1) as usize; + +/// How one character's text item breaks. +#[derive(Clone, Copy, Debug)] +pub(crate) struct BreakContext { + /// The ICU rule set its content language and `line-break` select. + pub(crate) rule_set: RuleSet, + pub(crate) word_break: WordBreak, + /// `line-break: anywhere`. + pub(crate) anywhere: bool, + /// Whether its white space collapses. + pub(crate) collapses: bool, +} + +/// A paragraph's break opportunities, by the byte offset of the character +/// after each boundary. +#[derive(Debug, Default)] +pub(crate) struct BreakOpportunities { + verdicts: Vec>, +} + +impl BreakOpportunities { + /// The opportunities in `text`, each character read in the context + /// `context_at` gives its byte offset, and each boundary broken as the + /// character after it says. + pub(crate) fn new(text: &str, context_at: impl Fn(usize) -> BreakContext) -> Self { + let offsets: Vec = text.char_indices().map(|(offset, _)| offset).collect(); + let chars: Vec = text.chars().collect(); + let contexts: Vec = + offsets.iter().map(|&offset| context_at(offset)).collect(); + let rule_sets: Vec = contexts.iter().map(|context| context.rule_set).collect(); + let props: Vec = chars + .iter() + .zip(&rule_sets) + .map(|(&character, &rule_set)| classes::props(character, rule_set)) + .collect(); + let rules = uax14::opportunities(&chars, &props, &rule_sets); + let iterated: Vec> = (0..chars.len()) + .map(|index| match index { + 0 => Some(false), + _ if contexts[index].anywhere => Some(true), + _ => blink_verdict(&chars, &props, &rules, index, contexts[index]), + }) + .collect(); + let mut by_index = iterated.clone(); + for (index, verdict) in iterated.iter().enumerate() { + if *verdict != Some(true) { + continue; + } + let end = line_end(&chars, &contexts, index); + if end != index { + by_index[index] = Some(false); + if let Some(slot) = by_index.get_mut(end) { + *slot = Some(true); + } + } + } + let mut verdicts = vec![None; text.len() + 1]; + for (index, verdict) in by_index.into_iter().enumerate().skip(1) { + verdicts[offsets[index]] = verdict; + } + Self { verdicts } + } + + /// Whether a line may break before the character at byte `offset`; + /// `None` leaves the boundary to the dictionary. + pub(crate) fn before(&self, offset: usize) -> Option { + self.verdicts.get(offset).copied().flatten() + } +} + +fn blink_verdict( + chars: &[char], + props: &[classes::Props], + rules: &[bool], + index: usize, + context: BreakContext, +) -> Option { + let breakable_space = |character: char| matches!(character, ' ' | '\t' | '\n'); + let (before, after) = (chars[index - 1], chars[index]); + if breakable_space(after) { + return Some(false); + } + if breakable_space(before) { + return Some(true); + } + let fast = fast_verdict(chars, index); + if fast == Some(true) { + return Some(true); + } + match context.word_break { + WordBreak::BreakAll => { + let loose = context.rule_set.loose(); + let last = chars[..index] + .iter() + .rev() + .map(|&character| word_break::break_all_class(character)) + .find(|&class| class != icu_properties::props::LineBreak::CombiningMark) + .unwrap_or(icu_properties::props::LineBreak::Unknown); + // `loose` breaks before a hyphen after a letter, a number or + // an ideograph. + let hyphen_after_word = matches!(after, '\u{2010}' | '\u{2013}') + && matches!( + last, + icu_properties::props::LineBreak::Numeric + | icu_properties::props::LineBreak::Alphabetic + | icu_properties::props::LineBreak::ComplexContext + | icu_properties::props::LineBreak::Ideographic + ); + if (loose && hyphen_after_word) + || word_break::break_all_breaks( + last, + word_break::break_all_class(after), + after, + loose, + ) + { + return Some(true); + } + } + WordBreak::KeepAll => { + let before_last = index.checked_sub(2).map(|at| chars[at]); + if word_break::keep_all_keeps(before_last, before, after) { + return Some(false); + } + } + WordBreak::Normal => {} + } + if fast == Some(false) { + return Some(false); + } + if props[index - 1].complex && props[index].complex { + return None; + } + Some(rules[index]) +} + +/// Blink's fast verdict (`ShouldBreakFast`): `Some` for a pair it decides +/// without the Unicode rules, `None` otherwise. +fn fast_verdict(chars: &[char], index: usize) -> Option { + let (before, after) = (chars[index - 1], chars[index]); + let (before_code, after_code) = (u32::from(before), u32::from(after)); + if before_code < TABLE_FIRST || after_code < TABLE_FIRST { + return Some(false); + } + if before == '-' { + if after.is_ascii_digit() { + // A minus sign stays with its number; a hyphen inside a word + // or a number (an ID, a URL) may break. + return Some(index >= 2 && chars[index - 2].is_ascii_alphanumeric()); + } + if after_code > 0x7F { + return None; + } + } + (before_code <= TABLE_LAST && after_code <= TABLE_LAST) + .then(|| fast_table()[pair_index(before_code, after_code)]) +} + +/// Where a line broken before `index` ends (Blink's `ShapingLineBreaker` +/// then `LineBreaker::HandleTrailingSpaces`): the break moves back to the +/// start of the hangable spaces before it, and the line then takes the +/// spaces that follow as trailing — one space where white space collapses, +/// else the whole run of spaces and other space separators. +fn line_end(chars: &[char], contexts: &[BreakContext], index: usize) -> usize { + let hangable = + |character: char| matches!(character, ' ' | '\t') || is_other_space_separator(character); + let mut start = index; + while start > 0 && hangable(chars[start - 1]) { + start -= 1; + } + match chars.get(start) { + Some(' ') if contexts[start].collapses => start + 1, + Some(_) => { + let mut end = start; + while end < chars.len() && hangable(chars[end]) { + end += 1; + } + end + } + None => start, + } +} + +/// A space separator other than the space and the no-break space (Blink's +/// `unicode::IsOtherSpaceSeparator`). +fn is_other_space_separator(character: char) -> bool { + matches!( + character, + '\u{1680}' | '\u{2000}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' + ) +} + +fn pair_index(before: u32, after: u32) -> usize { + (before - TABLE_FIRST) as usize * TABLE_SIZE + (after - TABLE_FIRST) as usize +} + +/// Blink's fast line break table (`LineBreakData`): every pair of +/// characters from `!` to U+00FF as ICU's English line iterator breaks +/// the two alone, then the printable ASCII pairs overridden for +/// compatibility (`FillAscii`). +fn fast_table() -> &'static [bool] { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut table = vec![false; TABLE_SIZE * TABLE_SIZE]; + for before in TABLE_FIRST..=TABLE_LAST { + for after in TABLE_FIRST..=TABLE_LAST { + let pair = [before, after].map(|code| char::from_u32(code).unwrap_or('\u{FFFD}')); + let props = pair.map(|character| classes::props(character, RuleSet::Normal)); + table[pair_index(before, after)] = + uax14::opportunities(&pair, &props, &[RuleSet::Normal; 2])[1]; + } + } + fill_ascii(&mut table); + table + }) +} + +fn fill_ascii(table: &mut [bool]) { + const ALL: (char, char) = ('!', '\u{7F}'); + let mut set = |before: (char, char), after: (char, char), value: bool| { + for b in u32::from(before.0)..=u32::from(before.1) { + for a in u32::from(after.0)..=u32::from(after.1) { + table[pair_index(b, a)] = value; + } + } + }; + let one = |character: char| (character, character); + set(ALL, ALL, false); + set(ALL, one('('), true); + set(ALL, one('<'), true); + set(ALL, one('['), true); + set(ALL, one('{'), true); + set(one('-'), ALL, true); + set(one('?'), ALL, true); + set(one('-'), one('$'), false); + set(ALL, one('!'), false); + set(one('?'), one('"'), false); + set(one('?'), one('\''), false); + set(ALL, one(')'), false); + set(ALL, one(','), false); + set(ALL, one('.'), false); + set(ALL, one('/'), false); + set(one('-'), ('0', '9'), false); + set(ALL, one(':'), false); + set(ALL, one(';'), false); + set(ALL, one('?'), false); + set(ALL, one(']'), false); + set(ALL, one('}'), false); + set(one('$'), ALL, false); + set(one('\''), ALL, false); + set(one('('), ALL, false); + set(one('/'), ALL, false); + set(('0', '9'), ALL, false); + set(one('<'), ALL, false); + set(one('@'), ALL, false); + set(('A', 'Z'), ALL, false); + set(one('['), ALL, false); + set(('^', '`'), ALL, false); + set(('a', 'z'), ALL, false); + set(one('{'), ALL, false); + set(one('\u{7F}'), ALL, false); +} diff --git a/crates/rito-inline/src/break_opportunities/uax14.rs b/crates/rito-inline/src/break_opportunities/uax14.rs new file mode 100644 index 000000000..7eabdc6fc --- /dev/null +++ b/crates/rito-inline/src/break_opportunities/uax14.rs @@ -0,0 +1,413 @@ +//! The UAX #14 line breaking rules (Unicode 17, LB4–LB31) as ICU's rule +//! files state them, read in order at each boundary, with the tailorings +//! the rule sets add: `loose` breaks between inseparables, and +//! `loose_cj` breaks between an ideograph and a hyphen and narrows the +//! numeric prefixes and postfixes. + +use super::classes::{Class, Props, RuleSet}; +use Class::*; + +/// The dotted circle, which stands for an aksara in LB28a. +const DOTTED_CIRCLE: char = '\u{25CC}'; + +/// For each character, whether a line may break before it (never before +/// the first). +pub(super) fn opportunities(chars: &[char], props: &[Props], rule_sets: &[RuleSet]) -> Vec { + let text = Text::new(chars, props); + let mut breaks = vec![false; chars.len()]; + for index in 1..chars.len() { + breaks[index] = text.breaks_before(index, rule_sets[index], &breaks[..index]); + } + breaks +} + +struct Text<'a> { + chars: &'a [char], + props: &'a [Props], + /// Each character's own class. + class: Vec, + /// The character a combining mark takes its class from (LB9): the + /// start of its combining sequence; a character's own index + /// otherwise. + base: Vec, + /// The class each character's combining sequence reads as (LB9, + /// LB10: a mark with nothing to combine with is a letter). + unit: Vec, + /// Whether the sequence ending here is a number followed by + /// separators: NU (SY | IS)* (LB25). + number: Vec, + /// Regional indicators in a row ending here (LB30a). + indicators: Vec, +} + +impl<'a> Text<'a> { + fn new(chars: &'a [char], props: &'a [Props]) -> Self { + let class: Vec = props.iter().map(|props| props.class).collect(); + let mut base = Vec::with_capacity(chars.len()); + for index in 0..chars.len() { + let attaches = index > 0 + && matches!(class[index], Cm | Zwj) + && !matches!(class[base[index - 1]], Bk | Cr | Lf | Nl | Sp | Zw); + base.push(if attaches { base[index - 1] } else { index }); + } + let unit: Vec = base + .iter() + .map(|&start| match class[start] { + Cm | Zwj => Al, + other => other, + }) + .collect(); + let mut number = vec![false; chars.len()]; + let mut indicators = vec![0; chars.len()]; + for index in 0..chars.len() { + if base[index] != index { + number[index] = number[base[index]]; + indicators[index] = indicators[base[index]]; + continue; + } + let previous = index.checked_sub(1); + number[index] = match unit[index] { + Nu => true, + Sy | Is => previous.is_some_and(|previous| number[previous]), + _ => false, + }; + indicators[index] = if unit[index] == Ri { + 1 + previous.map_or(0, |previous| indicators[previous]) + } else { + 0 + }; + } + Self { + chars, + props, + class, + base, + unit, + number, + indicators, + } + } + + /// The unit before the one holding `index`. + fn previous_unit(&self, index: usize) -> Option { + self.base[index].checked_sub(1) + } + + /// The unit after the one starting at `index`. + fn next_unit(&self, index: usize) -> Option { + (index + 1..self.chars.len()).find(|&next| self.base[next] == next) + } + + /// The unit before the spaces that end at `index - 1`, if any. + fn before_spaces(&self, index: usize) -> Option { + let mut at = index - 1; + while self.class[at] == Sp { + at = at.checked_sub(1)?; + } + Some(at) + } + + fn east_asian(&self, index: usize) -> bool { + self.props[self.base[index]].east_asian + } + + fn is_aksara(&self, index: usize) -> bool { + matches!(self.unit[index], Ak | As) || self.chars[self.base[index]] == DOTTED_CIRCLE + } + + /// Whether a line may break before `index`, given the verdicts before + /// it (`earlier`). + fn breaks_before(&self, index: usize, rule_set: RuleSet, earlier: &[bool]) -> bool { + let before = self.class[index - 1]; + let after = self.class[index]; + // LB4, LB5: hard breaks. + if before == Bk { + return true; + } + if before == Cr && after == Lf { + return false; + } + if matches!(before, Cr | Lf | Nl) { + return true; + } + // LB6, LB7. + if matches!(after, Bk | Cr | Lf | Nl | Sp | Zw) { + return false; + } + // LB8: ZW SP* ÷ + if self + .before_spaces(index) + .is_some_and(|at| self.class[at] == Zw) + { + return true; + } + // LB8a, LB9. + if before == Zwj || self.base[index] != index { + return false; + } + let left = self.unit[index - 1]; + let right = self.unit[index]; + self.pairs(index, left, right, rule_set, earlier) + .unwrap_or_else(|| self.numbers_and_letters(index, left, right, rule_set)) + } + + /// LB11–LB22: a verdict, or `None` to read on. + fn pairs( + &self, + index: usize, + left: Class, + right: Class, + rule_set: RuleSet, + earlier: &[bool], + ) -> Option { + let left_start = self.base[index - 1]; + // LB11, LB12, LB12a, LB13. + if right == Wj || left == Wj || left == Gl { + return Some(false); + } + if right == Gl && !matches!(left, Sp | Ba | Hy | Hh) { + return Some(false); + } + if matches!(right, Cl | Cp | Ex | Sy) { + return Some(false); + } + let spaced = self.before_spaces(index).map(|at| self.unit[at]); + // LB14: OP SP* × + if spaced == Some(Op) { + return Some(false); + } + // LB15a: (sot | BK | CR | LF | NL | OP | QU | GL | SP | ZW) Pi SP* × + if let Some(at) = self.before_spaces(index) { + if self.unit[at] == Qu && self.props[self.base[at]].initial_quote { + let opens = self.previous_unit(at).is_none_or(|previous| { + matches!( + self.unit[previous], + Bk | Cr | Lf | Nl | Op | Qu | Gl | Sp | Zw + ) + }); + if opens { + return Some(false); + } + } + } + // LB15b: × Pf (SP | GL | WJ | CL | QU | CP | EX | IS | SY | BK | CR + // | LF | NL | ZW | eot) + if right == Qu && self.props[index].final_quote { + let closes = self.next_unit(index).is_none_or(|next| { + matches!( + self.unit[next], + Sp | Gl | Wj | Cl | Qu | Cp | Ex | Is | Sy | Bk | Cr | Lf | Nl | Zw + ) + }); + if closes { + return Some(false); + } + } + // LB15c: SP ÷ IS NU; LB15d: × IS. + if right == Is { + let number_follows = self + .next_unit(index) + .is_some_and(|next| self.unit[next] == Nu); + return Some(left == Sp && number_follows); + } + // LB16: (CL | CP) SP* × NS; LB17: B2 SP* × B2. + if right == Ns && matches!(spaced, Some(Cl | Cp)) { + return Some(false); + } + if right == B2 && spaced == Some(B2) { + return Some(false); + } + // LB18. + if left == Sp { + return Some(true); + } + // LB19: × [QU - Pi]; [QU - Pf] × + if right == Qu && !self.props[index].initial_quote { + return Some(false); + } + if left == Qu && !self.props[left_start].final_quote { + return Some(false); + } + // LB19a: a quotation mark not between East Asian characters. + if right == Qu { + let east_asian_after = self + .next_unit(index) + .is_some_and(|next| self.east_asian(next)); + if !self.east_asian(index - 1) || !east_asian_after { + return Some(false); + } + } + if left == Qu { + let east_asian_before = self + .previous_unit(index - 1) + .is_some_and(|previous| self.east_asian(previous)); + if !self.props[index].east_asian || !east_asian_before { + return Some(false); + } + } + // LB20. + if right == Cb || left == Cb { + return Some(true); + } + // LB20a: a hyphen that starts a word stays with it. ICU reads a + // word start as any break opportunity before the hyphen (its + // `^(HY | HH) × (AL | HL)`), or a space, a glue or a contingent + // break before it. + if matches!(left, Hy | Hh) && matches!(right, Al | Hl) { + let starts_word = earlier[left_start] + || self + .previous_unit(index - 1) + .is_none_or(|previous| matches!(self.unit[previous], Sp | Gl | Cb)); + if starts_word { + return Some(false); + } + } + // LB21: × (BA | HH | HY | NS); BB ×. `loose_cj` breaks ID ÷ HH. + if matches!(right, Ba | Hh | Hy | Ns) && !(rule_set.loose_cj() && left == Id && right == Hh) + { + return Some(false); + } + if left == Bb { + return Some(false); + } + // LB21a: HL (HY | HH) × [^HL]; LB21b: SY × HL. + if matches!(left, Hy | Hh) + && right != Hl + && self + .previous_unit(index - 1) + .is_some_and(|previous| self.unit[previous] == Hl) + { + return Some(false); + } + if left == Sy && right == Hl { + return Some(false); + } + // LB22: × IN. `loose` breaks between inseparables. + if right == In && !(rule_set.loose() && left == In) { + return Some(false); + } + None + } + + /// LB23–LB31. + fn numbers_and_letters( + &self, + index: usize, + left: Class, + right: Class, + rule_set: RuleSet, + ) -> bool { + let left_start = self.base[index - 1]; + let alphabetic = |class: Class| matches!(class, Al | Hl); + // LB23. + if (alphabetic(left) && right == Nu) || (left == Nu && alphabetic(right)) { + return false; + } + // LB23a. + if (left == Pr && matches!(right, Id | Eb | Em)) + || (matches!(left, Id | Eb | Em) && right == Po) + { + return false; + } + // LB24 (`loose_cj` keeps its postfix exceptions here). + let affix = |class: Class| matches!(class, Pr | Po | PoX); + if (affix(left) && alphabetic(right)) || (alphabetic(left) && affix(right)) { + return false; + } + if !self.within_number(index, left, right, rule_set) { + return false; + } + // LB26, LB27: Korean syllables. + let hangul = |class: Class| matches!(class, Jl | Jv | Jt | H2 | H3); + if (left == Jl && matches!(right, Jl | Jv | H2 | H3)) + || (matches!(left, Jv | H2) && matches!(right, Jv | Jt)) + || (matches!(left, Jt | H3) && right == Jt) + || (hangul(left) && right == Po) + || (left == Pr && hangul(right)) + { + return false; + } + // LB28. + if alphabetic(left) && alphabetic(right) { + return false; + } + // LB28a: orthographic syllables. + if (left == Ap && self.is_aksara(index)) + || (self.is_aksara(index - 1) && matches!(right, Vf | Vi)) + || (left == Vi + && (right == Ak || self.chars[index] == DOTTED_CIRCLE) + && self + .previous_unit(index - 1) + .is_some_and(|previous| self.is_aksara(previous))) + || (self.is_aksara(index - 1) + && self.is_aksara(index) + && self + .next_unit(index) + .is_some_and(|next| self.unit[next] == Vf)) + { + return false; + } + // LB29, LB30. + if left == Is && alphabetic(right) { + return false; + } + if matches!(left, Al | Hl | Nu) && right == Op && !self.props[index].east_asian { + return false; + } + if left == Cp && !self.props[left_start].east_asian && matches!(right, Al | Hl | Nu) { + return false; + } + // LB30a: regional indicators pair. + if left == Ri && right == Ri && self.indicators[index - 1] % 2 == 1 { + return false; + } + // LB30b. + if right == Em && (left == Eb || self.props[left_start].unassigned_pictograph) { + return false; + } + // LB31. + true + } + + /// LB25: whether the boundary may break as far as numbers go + /// (`(PR | PO)? (OP | HY)? IS? NU (NU | SY | IS)* (CL | CP)? + /// (PR | PO)?` stays together; `loose_cj` opens with its postfix + /// exceptions and closes with its prefix exceptions too). + fn within_number(&self, index: usize, left: Class, right: Class, rule_set: RuleSet) -> bool { + let suffix = + |class: Class| matches!(class, Pr | Po) || (rule_set.loose_cj() && class == PrX); + let prefix = |class: Class| matches!(class, Pr | Po | PoX); + let after_number = self.number[index - 1]; + if after_number && (right == Nu || suffix(right)) { + return false; + } + if matches!(left, Cl | Cp) + && suffix(right) + && self + .previous_unit(index - 1) + .is_some_and(|previous| self.number[previous]) + { + return false; + } + if prefix(left) { + if right == Nu { + return false; + } + if right == Op { + let next = self.next_unit(index); + let next_is = |class: Class| next.is_some_and(|next| self.unit[next] == class); + let after_separator = next + .filter(|&next| self.unit[next] == Is) + .and_then(|next| self.next_unit(next)) + .is_some_and(|next| self.unit[next] == Nu); + if next_is(Nu) || after_separator { + return false; + } + } + } + if matches!(left, Hy | Is) && right == Nu { + return false; + } + true + } +} diff --git a/crates/rito-inline/src/break_opportunities/word_break.rs b/crates/rito-inline/src/break_opportunities/word_break.rs new file mode 100644 index 000000000..78dba0daa --- /dev/null +++ b/crates/rito-inline/src/break_opportunities/word_break.rs @@ -0,0 +1,94 @@ +//! Blink's `word-break: break-all` and `keep-all` (`LazyLineBreakIterator` +//! with `LineBreakType::kBreakAll` and `kKeepAll`): break-all adds the +//! breaks its class table lists to the normal ones, keep-all keeps letters +//! and numbers together. + +use icu_properties::props::{GeneralCategory, LineBreak as Lb}; +use icu_properties::CodePointMapData; + +/// The raw line breaking class Blink's break-all reads +/// (`LineBreakPropertyValue`): `+` counts as a letter. +pub(super) fn break_all_class(character: char) -> Lb { + if character == '+' { + Lb::Alphabetic + } else { + CodePointMapData::::new().get(character) + } +} + +/// Whether break-all breaks between a character of class `before` and +/// `after` (`ShouldBreakAfterBreakAll` over +/// `kBreakAllLineBreakClassTable`); `false` leaves the boundary to the +/// normal rules. A break before a break-after character other than `|` +/// waits for `loose`. +pub(super) fn break_all_breaks(before: Lb, after: Lb, character: char, loose: bool) -> bool { + use Lb as L; + let letters = |class: Lb| { + matches!( + class, + L::Ambiguous | L::Alphabetic | L::BreakAfter | L::Hyphen | L::Numeric | L::HebrewLetter + ) + }; + let listed = match before { + L::Ambiguous + | L::Alphabetic + | L::BreakAfter + | L::Numeric + | L::ComplexContext + | L::BreakSymbols + | L::HebrewLetter => { + letters(after) + || matches!( + after, + L::OpenPunctuation | L::PrefixNumeric | L::ComplexContext + ) + } + L::ClosePunctuation => letters(after) || after == L::PrefixNumeric, + L::CloseParenthesis => { + letters(after) || matches!(after, L::PrefixNumeric | L::ComplexContext) + } + L::Exclamation | L::PostfixNumeric => { + letters(after) || matches!(after, L::PostfixNumeric | L::PrefixNumeric) + } + L::InfixNumeric => letters(after), + L::Hyphen => after == L::Numeric, + L::PrefixNumeric => after == L::PostfixNumeric, + _ => false, + }; + listed && (after != L::BreakAfter || character == '|' || loose) +} + +/// Whether keep-all keeps the boundary before `after` (`last` before it, +/// `before_last` before that) together: between letters and numbers, +/// a combining mark reading as the character it follows, outside +/// dictionary-broken scripts (`ShouldKeepAfterKeepAll`). +pub(super) fn keep_all_keeps(before_last: Option, last: char, after: char) -> bool { + let category = |character: char| CodePointMapData::::new().get(character); + let is_mark = |character: char| { + matches!( + category(character), + GeneralCategory::NonspacingMark + | GeneralCategory::SpacingMark + | GeneralCategory::EnclosingMark + ) + }; + let word_character = |character: char| { + matches!( + category(character), + GeneralCategory::UppercaseLetter + | GeneralCategory::LowercaseLetter + | GeneralCategory::TitlecaseLetter + | GeneralCategory::ModifierLetter + | GeneralCategory::OtherLetter + | GeneralCategory::DecimalNumber + | GeneralCategory::LetterNumber + | GeneralCategory::OtherNumber + ) && CodePointMapData::::new().get(character) != Lb::ComplexContext + }; + let reading = if is_mark(last) { + before_last.unwrap_or(last) + } else { + last + }; + word_character(reading) && word_character(after) +} diff --git a/crates/rito-inline/src/breaking.rs b/crates/rito-inline/src/breaking.rs index ecfb950b9..414fa14b0 100644 --- a/crates/rito-inline/src/breaking.rs +++ b/crates/rito-inline/src/breaking.rs @@ -4,117 +4,6 @@ use crate::*; -pub(crate) fn break_anywhere_override(_context: parley::LineBreakContext) -> Option { - Some(true) -} - -/// Fills the one gap in Parley's `word-break: break-all` relaxation. -/// -/// Under break-all Parley already breaks between latin letters (a|b) and -/// before the prolonged sound mark (ず|ー), but it still carries the CJK -/// novel dash pair ── (U+2500 BOX DRAWINGS LIGHT HORIZONTAL) as one -/// unbreakable word, while Blink splits it across the line boundary -/// (b93 truth: the first ─ closes the line, the second opens the next). -/// Everything else defers to Parley's break-all logic. -pub(crate) fn break_all_box_dash_override(context: parley::LineBreakContext) -> Option { - if context.before == '\u{2500}' && context.after == '\u{2500}' { - return Some(true); - } - None -} - -/// Chromium's line-break tailoring, extended with its CJK quote classes -/// ([`cjk_quote_reclassification`]) and the CJ line-start relaxation. -/// Everything else defers to Parley's Chromium ASCII table. -pub(crate) fn cjk_aware_chromium_break_override(context: parley::LineBreakContext) -> Option { - if let Some(verdict) = cjk_quote_reclassification(context) { - return Some(verdict); - } - // Blink's default line-break (auto/normal/loose — measured matrix - // zh-CN/ja/en × auto/normal/loose, 2026-08-11) resolves the UAX-14 - // CJ class (small kana + the prolonged sound mark) to ID: the - // character may START a line (b39 truth: あず|ーる splits with ー - // opening the next line). Parley keeps CJ as NS, so the pair - // retreated whole. Only `line-break: strict` keeps the prohibition — - // that path installs the strict variant below. - if is_cj_conditional_starter(context.after) - && is_cjk_context(context.before) - && !['\u{2018}', '\u{201C}'].contains(&context.before) - && fullwidth_punctuation_class(context.before) != PunctuationClass::Open - { - return Some(true); - } - (parley::CHROMIUM_LINE_BREAK_OVERRIDE)(context) -} - -/// The `line-break: strict` variant: Chromium's quote reclassification -/// without the CJ line-start relaxation (strict keeps CJ as NS, measured -/// PAIR-RETREATS on the same matrix). -pub(crate) fn cjk_aware_chromium_break_override_strict( - context: parley::LineBreakContext, -) -> Option { - if let Some(verdict) = cjk_quote_reclassification(context) { - return Some(verdict); - } - (parley::CHROMIUM_LINE_BREAK_OVERRIDE)(context) -} - -/// UAX-14 gives the curly quotes class QU (no break on either side), but -/// Blink reclassifies them in CJK context: an opening curly quote breaks -/// like an opening bracket (opportunity before, none after) and a closing -/// curly quote like a closing bracket (opportunity after, none before). -/// CJK dialogue in translated novels hangs on this. -pub(crate) fn cjk_quote_reclassification(context: parley::LineBreakContext) -> Option { - const OPEN_QUOTES: [char; 2] = ['\u{2018}', '\u{201C}']; - const CLOSE_QUOTES: [char; 2] = ['\u{2019}', '\u{201D}']; - // A close/stop before the opening quote KEEPS the prohibition: the - // browser carries 说,'Caster' as one unbreakable block — the comma - // never sheds the quote that follows it (pinned-Chromium b112 line: - // …这点来 | 说,'Caster'… breaks before 说, not after the comma). - // Only an ideograph ahead of the opening quote releases the break. - if OPEN_QUOTES.contains(&context.after) - && is_cjk_context(context.before) - && matches!( - fullwidth_punctuation_class(context.before), - PunctuationClass::Other | PunctuationClass::Middle - ) - && !OPEN_QUOTES.contains(&context.before) - { - return Some(true); - } - // The em/horizontal-bar dashes join the after-side context: a - // closing curly quote breaks before a novel dash pair exactly like - // before an ideograph (pinned-Chromium b112 line: 怕”|——可见 puts - // the dash pair on the next line while the quote closes the first; - // treating the pair as unbreakable-after-quote dragged 怕” down - // with it and re-broke every following line of the chapter). - let after_joins_cjk = - is_cjk_context(context.after) || matches!(context.after, '\u{2014}' | '\u{2015}'); - if CLOSE_QUOTES.contains(&context.before) - && after_joins_cjk - && fullwidth_punctuation_class(context.after) != PunctuationClass::CloseOrStop - && !CLOSE_QUOTES.contains(&context.after) - && !OPEN_QUOTES.contains(&context.after) - { - return Some(true); - } - None -} - -/// The UAX-14 CJ class: small kana and the katakana-hiragana prolonged -/// sound mark, whose line-start prohibition is conditional on -/// `line-break` strictness. -pub(crate) fn is_cj_conditional_starter(character: char) -> bool { - matches!(u32::from(character), - 0x3041 | 0x3043 | 0x3045 | 0x3047 | 0x3049 - | 0x3063 | 0x3083 | 0x3085 | 0x3087 | 0x308E | 0x3095 | 0x3096 - | 0x30A1 | 0x30A3 | 0x30A5 | 0x30A7 | 0x30A9 - | 0x30C3 | 0x30E3 | 0x30E5 | 0x30E7 | 0x30EE | 0x30F5 | 0x30F6 - | 0x30FC - | 0x31F0..=0x31FF - | 0xFF67..=0xFF70) -} - /// Whether the character puts the boundary in CJK typographic context. pub(crate) fn is_cjk_context(character: char) -> bool { matches!(u32::from(character), @@ -128,10 +17,6 @@ pub(crate) fn is_cjk_context(character: char) -> bool { | 0x20000..=0x3FFFF) } -/// Layout-unit epsilon for line-fit comparisons, Chromium's `LayoutUnit` -/// quantum (1/64 px). -pub(crate) const LINE_FIT_EPS: f32 = 1.0 / 64.0; - /// How many glyphs past a soft break the candidate scan follows. The /// dragged-down tail is whatever could not break before the closer — an /// entire unbreakable Latin word included (measured: 有點melancholy。」, @@ -142,89 +27,6 @@ pub(crate) const LINE_FIT_EPS: f32 = 1.0 / 64.0; /// not a dragged closer. pub(crate) const LINE_END_TRIM_SCAN: usize = 64; -/// Detects a rejected-extension rewind on `line_index`: the line-end trim -/// extension would fit the first overflowing closer (so a single-item -/// line would have extended), but the line crosses an element boundary, -/// which Blink answers by rewinding the WHOLE overflowing item to the -/// next line — measured on razor-fit note-box lines, where line one keeps -/// only the leading `①` span while greedy would split the text item. -/// Returns the cluster count the line must be forced to hold. -pub(crate) fn rewind_break_count( - catalog: &FontCatalog, - layout: &parley::Layout<[u8; 4]>, - text: &str, - line_index: usize, - max_advance: f64, - item_ranges: &[std::ops::Range], - bounds: &mut WidthBounds, -) -> Option { - let line = layout.get(line_index)?; - let next = layout.get(line_index + 1)?; - if line.break_reason() != parley::layout::BreakReason::Regular { - return None; - } - // Only a line crossing an element boundary rewinds; a single-item - // line extends instead (see `line_end_trim_candidate`). - let mut items = line_item_keys(&line); - let first = items.next(); - if !items.any(|item| Some(item) != first) { - return None; - } - let metrics = line.metrics(); - let mut advance = f64::from(metrics.advance - metrics.trailing_whitespace); - let next_range = next.text_range(); - let mut cluster = parley::layout::Cluster::from_byte_index(layout, next_range.start)?; - for _ in 0..LINE_END_TRIM_SCAN { - let byte = cluster.text_range().start; - if byte >= next_range.end { - return None; - } - let character = text[byte..].chars().next()?; - let cluster_advance = f64::from(cluster.advance()); - if bounds.fits( - advance + cluster_advance, - max_advance + f64::from(LINE_FIT_EPS), - ) { - advance += cluster_advance; - cluster = cluster.next_logical()?; - continue; - } - if !cluster_font_has_halt(catalog, &cluster) { - return None; - } - if !line_end_trim_eligible(character) { - return None; - } - let trimmed = cluster_advance - 0.5 * f64::from(cluster.run().font_size()); - if !bounds.fits(advance + trimmed, max_advance + f64::from(LINE_FIT_EPS)) { - return None; - } - // Only a PARAGRAPH-FINAL candidate rewinds (measured: `……。)啊` - // with content after the closer breaks greedily; the razor-fit - // note line whose `)` ends the paragraph rewinds its whole item). - let candidate_end = byte + character.len_utf8(); - if !text - .get(candidate_end..) - .is_some_and(|rest| rest.chars().all(char::is_whitespace)) - { - return None; - } - // The extension would fit; the rewound item is the one holding - // the candidate, and it must begin inside this line. - let item_start = item_ranges - .iter() - .find(|range| range.contains(&byte)) - .map(|range| range.start)?; - let line_start = line.text_range().start; - if item_start <= line_start { - return None; - } - let count = text.get(line_start..item_start)?.chars().count(); - return u32::try_from(count).ok().filter(|count| *count > 0); - } - None -} - /// The first character after `line_index`'s soft break that did not fit, /// if extending the line by exactly that character with its blank right /// half trimmed could keep it on the line. `decided` says whether a @@ -234,10 +36,18 @@ pub(crate) fn rewind_break_count( /// the candidate is the first character past the break that exceeds the /// available advance (characters before it fit and were only dragged down /// by break prohibitions), it must be an eligible closing glyph, and its -/// half-width advance must fit. The decision is a pre-filter only — the -/// caller re-lays the paragraph with the trim applied and keeps it only -/// if the line then breaks exactly after the trimmed closer, so parley's -/// own fitting (and its break rules) remain the authority. +/// half-width advance must fit `max_advance` (the line's width with its +/// fit epsilon). Blink extends within whichever text item overflows, so +/// element boundaries and atomic inlines earlier on the line do not +/// matter, and a closer ending its item keeps the trim (Chromium 153+, +/// `LineBreakerHanKerningEnd`; measured on 156: `①…)` and +/// `…永…)` both extend). A box gap riding on the closer +/// counts: Blink measures the closing tag after the trimmed text and +/// breaks the item before the closer when the two overflow together. The +/// decision is a pre-filter only — the caller re-lays the paragraph with +/// the trim applied and keeps it only if the line then breaks exactly +/// after the trimmed closer, so parley's own fitting (and its break +/// rules) remain the authority. #[allow(clippy::too_many_arguments)] pub(crate) fn line_end_trim_candidate( catalog: &FontCatalog, @@ -256,20 +66,6 @@ pub(crate) fn line_end_trim_candidate( if line.break_reason() != parley::layout::BreakReason::Regular { return None; } - // Blink skips the extension whenever the line crosses an element - // boundary (measured 2026-07-28, note-box ablation: a leading - // ① kills it at every size, alignment and vertical-align while - // a font-fallback split inside one element does not; flip widths - // 560.35 span vs 541.41 without). The line must be one inline item. - // An atomic inline is an element boundary by definition. - if line.has_inline_box() { - return None; - } - let mut items = line_item_keys(&line); - let first = items.next(); - if items.any(|item| Some(item) != first) { - return None; - } let metrics = line.metrics(); // Hung trailing whitespace is not measured against the available // advance; content is. @@ -283,12 +79,30 @@ pub(crate) fn line_end_trim_candidate( // the pair back on one line. Mirror that: measure these openers // trimmed, and hand the pairs back for un-suppression on accept. let mut unsuppress: Vec = Vec::new(); + // A box's start and end sizes (inline boxes of their own) take their + // width between the characters they stand between, as Blink adds an + // open or close tag before measuring the text after it. + let tags: Vec<(usize, parley::InlineBoxKind, f32)> = layout + .inline_boxes() + .iter() + .filter(|inline_box| inline_box.kind.is_tag()) + .map(|inline_box| (inline_box.index, inline_box.kind, inline_box.width)) + .collect(); + let tags_before = |from: usize, to: usize| -> f32 { + tags.iter() + .filter(|(index, ..)| from <= *index && *index <= to) + .map(|(_, _, width)| *width) + .sum() + }; + let mut scanned_from = next_range.start; for _ in 0..LINE_END_TRIM_SCAN { let byte = cluster.text_range().start; if byte >= next_range.end { return None; } let character = text[byte..].chars().next()?; + advance += tags_before(scanned_from, byte); + scanned_from = byte + 1; let mut cluster_advance = cluster.advance(); if let Some((_, left_byte)) = suppressed_openers .iter() @@ -297,7 +111,7 @@ pub(crate) fn line_end_trim_candidate( cluster_advance -= 0.5 * cluster.run().font_size(); unsuppress.push(*left_byte); } - if bounds.fits_f32(advance + cluster_advance, max_advance + LINE_FIT_EPS) { + if bounds.fits_f32(advance + cluster_advance, max_advance) { // Fits, so it only moved down under a break prohibition; the // overflowing character is further along. advance += cluster_advance; @@ -315,8 +129,28 @@ pub(crate) fn line_end_trim_candidate( if decided(byte) { return None; } + // The close tags right after the closer stay on its line; the open + // tags there go down with what they open. + let closer_end = byte + character.len_utf8(); + let closing: f32 = tags + .iter() + .filter(|(index, kind, _)| { + *index == closer_end && *kind == parley::InlineBoxKind::CloseTag + }) + .map(|(_, _, width)| *width) + .sum(); let trimmed = cluster_advance - 0.5 * cluster.run().font_size(); - if !bounds.fits_f32(advance + trimmed, max_advance + LINE_FIT_EPS) { + if !bounds.fits_f32(advance + trimmed + closing, max_advance) { + return None; + } + // A forced break right after the closer ends the line there at + // any width, and no soft break comes before a forced one: no line + // can end exactly after the trimmed closer, and the re-lay, which + // builds the whole paragraph again, could only reject the trim. + if cluster + .next_logical() + .is_some_and(|next| next.is_hard_line_break()) + { return None; } return Some((byte, unsuppress)); @@ -327,7 +161,9 @@ pub(crate) fn line_end_trim_candidate( /// Whether a line of the paragraph could take a line-end trim, or the /// rewind it is rejected into, at any width: both need an eligible closer /// (see [`line_end_trim_eligible`]) shaped in a face with `halt`, and -/// shaping puts a character in the same face at every width. +/// shaping puts a character in the same face at every width. It reads the +/// runs, so it answers before the paragraph is broken into lines; either +/// direction's feature makes a trim possible. pub(crate) fn line_end_trims_possible( catalog: &FontCatalog, layout: &parley::Layout<[u8; 4]>, @@ -336,8 +172,9 @@ pub(crate) fn line_end_trims_possible( text.char_indices() .filter(|(_, character)| line_end_trim_eligible(*character)) .any(|(byte, _)| { - parley::layout::Cluster::from_byte_index(layout, byte) - .is_some_and(|cluster| cluster_font_has_halt(catalog, &cluster)) + layout + .font_at(byte) + .is_some_and(|font| catalog.has_halt(font, false) || catalog.has_halt(font, true)) }) } @@ -359,53 +196,6 @@ pub(crate) fn line_end_trims_possible( /// trailing 法,pair down instead of trimming the comma; b20's real /// `看法,` line stays open evidence, see the task archive.) pub(crate) fn line_end_trim_eligible(character: char) -> bool { - matches!( - character, - '」' | '』' - | ')' - | '】' - | '〕' - | '》' - | '〉' - | '〗' - | '〙' - | '〛' - | '}' - | ']' - | '⦆' - | '’' - | '”' - ) -} - -/// Applies accepted line-end trims as negative letter-spacing on the -/// closing glyph itself — the same mechanism as the pair trims, so the -/// trimmed character is isolated in its own glyph run and the paint stays -/// position-exact. The blank right half collapses; the ink does not move. -pub(crate) fn push_line_end_trims( - builder: &mut SpacingBuilder<'_>, - text: &str, - runs: &[(std::ops::Range, &InlineFormattingStyle, usize)], - end_trims: &[usize], -) { - for &byte in end_trims { - let Some(character) = text[byte..].chars().next() else { - continue; - }; - let Some(style) = runs - .iter() - .find(|(range, ..)| range.contains(&byte)) - .map(|(_, style, _)| *style) - else { - continue; - }; - let author = match style.text_flow.letter_spacing { - LengthPercentage::Length(px) => px.get(), - _ => 0.0, - }; - builder.push( - StyleProperty::LetterSpacing(author - 0.5 * shaping_font_size(style.font.size.get())), - byte..byte + character.len_utf8(), - ); - } + fullwidth_punctuation_class(character) == PunctuationClass::CloseOrStop + && !matches!(character, '。' | '、' | ',' | '.' | ':' | ';') } diff --git a/crates/rito-inline/src/clusters.rs b/crates/rito-inline/src/clusters.rs index bd6b96da8..f20a00165 100644 --- a/crates/rito-inline/src/clusters.rs +++ b/crates/rito-inline/src/clusters.rs @@ -49,8 +49,13 @@ struct ClusterStep { face: FontFaceId, font_size: f64, /// Whether the step is the cluster's bare glyph advance: no spacing - /// folded in, no kerning, no glyph offset, left to right. + /// folded in, no kerning, no glyph offset, left to right. For a + /// ligature, whether the whole ligature steps by its glyph's advance, + /// held by its last cluster. bare: bool, + /// Whether the cluster continues a ligature: it has no glyph of its + /// own, and only the string drawn from the ligature's start draws it. + continues: bool, } impl ClusterSteps { @@ -59,19 +64,54 @@ impl ClusterSteps { layout: &parley::Layout<[u8; 4]>, spacing_edits: &SpacingEdits, ) -> Self { - let mut steps = Vec::new(); + let mut steps: Vec = Vec::new(); + // The open ligature: its start's step index and its shaped and + // declared advances so far, `None` once any cluster of it is not + // bare. + let mut ligature: Option<(usize, Option<(f64, i64)>)> = None; let mut cluster = parley::layout::Cluster::from_byte_index(layout, 0); while let Some(current) = cluster { let text_range = current.text_range(); let folded = folded_spacing(spacing_edits, ¤t); + // A combined upright unit steps by its em alone, whatever its + // glyphs and the spacing around it. + let combined = current.is_combined(); + let units = (!combined && folded == 0.0) + .then(|| bare_units(catalog, ¤t)) + .flatten(); + let continues = current.is_ligature_continuation() && ligature.is_some(); + if continues { + if let Some((_, total)) = &mut ligature { + *total = total + .zip(units) + .map(|((shaped, declared), (more, own))| (shaped + more, declared + own)); + } + } else { + ligature = current.is_ligature_start().then_some((steps.len(), units)); + } steps.push(ClusterStep { start: text_range.start as u32, end: text_range.end as u32, - step: hb_fixed_cluster_advance(catalog, ¤t, folded), + step: if combined { + f64::from(current.advance()) + } else { + hb_fixed_cluster_advance(catalog, ¤t, folded) + }, face: catalog.face_id(current.run().font()), font_size: f64::from(current.run().font_size()), - bare: folded == 0.0 && is_bare(catalog, ¤t), + bare: units.is_some_and(|(shaped, declared)| shaped.round() as i64 == declared), + continues, }); + // A ligature steps as one: its last cluster says whether it + // stepped by its glyph's advance. + if let Some((_, total)) = ligature { + if let Some(last) = steps.last_mut() { + if last.continues { + last.bare = total + .is_some_and(|(shaped, declared)| shaped.round() as i64 == declared); + } + } + } cluster = current.next_logical(); } Self { steps } @@ -95,34 +135,31 @@ impl ClusterSteps { } } -/// Whether `cluster` advances by its glyphs' declared advances alone: -/// shaping kerned none of them and offset none, and it runs left to right -/// in a face at its default instance. Any shaper stepping through such a -/// cluster lands its successor at the same place. -fn is_bare( +/// `cluster`'s shaped advance and its glyphs' declared advances, in font +/// units, when shaping offset none of its glyphs and it runs left to right +/// in a face at its default instance; it is bare when the two agree (no +/// kerning), and any shaper stepping through it lands its successor at +/// the same place. +fn bare_units( catalog: &FontCatalog, cluster: &parley::layout::Cluster<'_, B>, -) -> bool { +) -> Option<(f64, i64)> { let run = cluster.run(); if run.is_rtl() || run.normalized_coords().iter().any(|coord| *coord != 0) { - return false; + return None; } let size = f64::from(run.font_size()); - let Some(upem) = catalog.units_per_em(run.font()).filter(|upem| *upem > 0) else { - return false; - }; + let upem = catalog + .units_per_em(run.font()) + .filter(|upem| *upem > 0 && size > 0.0)?; let mut declared = 0_i64; for glyph in cluster.glyphs() { if glyph.x != 0.0 || glyph.y != 0.0 { - return false; + return None; } - let Some(advance) = catalog.declared_advance(run.font(), glyph.id) else { - return false; - }; - declared += i64::from(advance); + declared += i64::from(catalog.declared_advance(run.font(), glyph.id)?); } - let shaped = f64::from(cluster.advance()) * upem as f64 / size; - size > 0.0 && shaped.round() as i64 == declared + Some((f64::from(cluster.advance()) * upem as f64 / size, declared)) } /// The cluster origins of the piece `range` of a laid-out paragraph, @@ -156,6 +193,7 @@ pub(crate) fn piece_clusters( trim, cluster.face, cluster.bare && justify_px == 0.0, + cluster.continues, ) }); let text = flow_text.get(range).unwrap_or_default(); @@ -167,7 +205,7 @@ pub(crate) fn piece_clusters( let advance = if grid { // Every origin is floored onto the grid on its own: none joins. let mut cumulative = 0.0_f64; - out.extend(steps.map(|(byte, step, trim, face, _)| { + out.extend(steps.map(|(byte, step, trim, face, _, _)| { let x = cumulative - trim; cumulative += step; ClusterPosition { @@ -175,6 +213,7 @@ pub(crate) fn piece_clusters( x, face, joins: false, + continues_ligature: false, } })); cumulative @@ -183,12 +222,13 @@ pub(crate) fn piece_clusters( // The previous cluster: whether it stepped by its bare advance // from an untrimmed origin, and its face. let mut previous: Option<(bool, FontFaceId)> = None; - out.extend(steps.map(|(byte, step, trim, face, bare)| { + out.extend(steps.map(|(byte, step, trim, face, bare, continues)| { let x = f64::from(pen) - trim; pen += step as f32; + // A ligature's later clusters draw only in its start's string. let joins = trim == 0.0 && previous.is_some_and(|(stepped_bare, previous_face)| { - stepped_bare && previous_face == face + (stepped_bare || continues) && previous_face == face }); previous = Some((bare && trim == 0.0, face)); ClusterPosition { @@ -196,6 +236,7 @@ pub(crate) fn piece_clusters( x, face, joins, + continues_ligature: continues, } })); f64::from(pen) @@ -285,10 +326,19 @@ impl ParleyInlineContext { let run = self.shaped_run(style, Some(annotation_size), false, text); let base = self.em_height(style, None, base_text); let annotation = self.em_height(style, Some(annotation_size), text); + let orientations = (LineBaseline::of(style) == LineBaseline::Central) + .then(|| { + let mut sized = style.clone(); + sized.font.size = + rito_style_contract::NonNegativeCssPx::new(annotation_size).ok()?; + Some(self.cluster_orientations(&sized, &run.clusters, text)) + }) + .flatten(); MeasuredRuby { run, over_offset: base.ascent + annotation.descent, em_ascent: annotation.primary_typo_ascent, + orientations, } } @@ -300,7 +350,9 @@ impl ParleyInlineContext { /// united, ceiled to whole pixels, and capped by the primary font's /// platform ascent and descent — the hhea metrics (typo when the /// face asks for them) rounded to whole pixels the way Skia reports - /// them to Blink. + /// them to Blink. A vertical line measures around its central + /// baseline instead: the em halved, capped by half the primary font's + /// height. fn em_height( &self, style: &InlineFormattingStyle, @@ -308,6 +360,7 @@ impl ParleyInlineContext { text: &str, ) -> EmHeight { let size = f64::from(size_override.unwrap_or(style.font.size.get())); + let baseline = LineBaseline::of(style); let mut united = (0.0_f64, 0.0_f64); if !text.is_empty() { let mut sized; @@ -325,7 +378,13 @@ impl ParleyInlineContext { let mut layouts = self.layouts.borrow_mut(); let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, text, 1.0, true)); - push_item_styles(&mut builder, &self.catalog, shaped_style, 0..text.len()); + push_item_styles( + &mut builder, + &self.catalog, + shaped_style, + text, + 0..text.len(), + ); let (mut layout, _) = builder.build(text); layout.break_all_lines(None); let mut seen: Vec<(u64, u32)> = Vec::new(); @@ -344,24 +403,30 @@ impl ParleyInlineContext { else { continue; }; - let (ascent, descent) = normalized_typo_height(&font_ref, size); + let (ascent, descent) = baseline.em_box(&font_ref, size); united.0 = united.0.max(ascent); united.1 = united.1.max(descent); } } } - let (primary_ascent, primary_descent, primary_typo_ascent) = self + let (primary_ascent, primary_descent, primary_typo_ascent, shift) = self .primary_font(style) .and_then(|(blob, index)| { let font_ref = skrifa::FontRef::from_index(blob.as_ref(), index).ok()?; let (ascent, descent, _) = rounded_metrics(&font_ref, size); + let (cap_ascent, cap_descent) = baseline.em_cap(ascent, descent); let (typo_ascent, _) = normalized_typo_height(&font_ref, size); - Some((ascent, descent, typo_ascent)) + Some(( + cap_ascent, + cap_descent, + typo_ascent, + baseline.em_shift(ascent, descent), + )) }) - .unwrap_or((f64::INFINITY, f64::INFINITY, 0.0)); + .unwrap_or((f64::INFINITY, f64::INFINITY, 0.0, 0.0)); EmHeight { - ascent: united.0.ceil().min(primary_ascent), - descent: united.1.ceil().min(primary_descent), + ascent: united.0.ceil().min(primary_ascent) + shift, + descent: united.1.ceil().min(primary_descent) - shift, primary_typo_ascent, } } @@ -392,6 +457,19 @@ impl ParleyInlineContext { size_override: Option, spacing: bool, text: &str, + ) -> MeasuredRun { + self.shaped_run_with(style, size_override, spacing, None, text) + } + + /// [`Self::shaped_run`] with the OpenType `feature` on over the whole + /// string. + pub(crate) fn shaped_run_with( + &self, + style: &InlineFormattingStyle, + size_override: Option, + spacing: bool, + feature: Option<&str>, + text: &str, ) -> MeasuredRun { if text.is_empty() { return MeasuredRun { @@ -414,13 +492,24 @@ impl ParleyInlineContext { let mut fonts = self.fonts.borrow_mut(); let mut layouts = self.layouts.borrow_mut(); let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, text, 1.0, true)); - push_item_styles(&mut builder, &self.catalog, style, 0..text.len()); + push_item_styles(&mut builder, &self.catalog, style, text, 0..text.len()); if !spacing { // Later pushes win: the style's spacing, pushed above, is // overridden to zero over the whole string. builder.push(parley::StyleProperty::LetterSpacing(0.0), 0..text.len()); builder.push(parley::StyleProperty::WordSpacing(0.0), 0..text.len()); } + if let Some(tag) = feature.and_then(|tag| <[u8; 4]>::try_from(tag.as_bytes()).ok()) { + builder.push( + parley::StyleProperty::FontFeatures(parley::FontFeatures::List( + std::borrow::Cow::Owned(vec![parley::FontFeature::new( + parley::setting::Tag::new(&tag), + 1, + )]), + )), + 0..text.len(), + ); + } let (mut layout, spacing_edits) = builder.build(text); layout.break_all_lines(None); let word_spacing = spacing @@ -461,6 +550,9 @@ pub struct MeasuredRuby { /// size: its primary font's OS/2 typo ascent normalized to the em /// (what a browser canvas's `textBaseline: 'top'` resolves to). pub em_ascent: f64, + /// How each annotation cluster stands when the line is vertical, from + /// the annotation's own baseline `over_offset` past the base's. + pub orientations: Option>, } /// A run's em height on Chromium's terms (see diff --git a/crates/rito-inline/src/context.rs b/crates/rito-inline/src/context.rs index 065602b63..852ced636 100644 --- a/crates/rito-inline/src/context.rs +++ b/crates/rito-inline/src/context.rs @@ -54,8 +54,13 @@ impl ParleyInlineContext { line_metrics: RefCell::default(), run_samples: RefCell::default(), halt_feature_cache: RefCell::default(), + upright_glyphs: RefCell::default(), + combined_settings: RefCell::default(), shaped: RefCell::default(), intrinsic_sizes: RefCell::default(), + block_layout: None, + #[cfg(test)] + counts: crate::counters::LineLoopCounts::default(), }) } diff --git a/crates/rito-inline/src/counters.rs b/crates/rito-inline/src/counters.rs new file mode 100644 index 000000000..fa58e1b81 --- /dev/null +++ b/crates/rito-inline/src/counters.rs @@ -0,0 +1,29 @@ +//! What the line loop did while laying a paragraph out, counted for the +//! tests that pin its cost: a paragraph is built (shaped) and broken into +//! lines a bounded number of times, however many line edges it decides. + +use std::cell::Cell; + +#[derive(Default)] +pub(crate) struct LineLoopCounts { + /// Paragraphs built because no kept build read the same. + pub(crate) builds: Cell, + /// Line breakings run because no kept breaking matched. + pub(crate) breaks: Cell, + /// Passes deciding a paragraph's line edges. + pub(crate) passes: Cell, +} + +impl LineLoopCounts { + pub(crate) fn note_build(&self) { + self.builds.set(self.builds.get() + 1); + } + + pub(crate) fn note_break(&self) { + self.breaks.set(self.breaks.get() + 1); + } + + pub(crate) fn note_pass(&self) { + self.passes.set(self.passes.get() + 1); + } +} diff --git a/crates/rito-inline/src/emphasis.rs b/crates/rito-inline/src/emphasis.rs new file mode 100644 index 000000000..25c90b2f3 --- /dev/null +++ b/crates/rito-inline/src/emphasis.rs @@ -0,0 +1,517 @@ +//! Emphasis marks on a line, as Blink sizes them. A mark is set in its +//! face at half the text's size, rounded to a whole pixel, and stands +//! that far outside the em box of the text it marks. A line's annotation +//! extent — how far its marks reach past its box, and how much room its +//! box leaves past the em boxes of its text — is Blink's +//! `ComputeAnnotationOverflow`, each text item's em box its text box drawn +//! in by its fonts' normalized typo metrics +//! (`AdjustTextOverUnderOffsetsForEmHeight`). + +use rito_fragment::FontFaceId; +use rito_style_contract::{LineSide, StyleId}; + +use crate::*; + +/// How far a line's annotations reach past its box, and how much room its +/// box leaves past the em boxes of its text, on each side (Blink's +/// `AnnotationMetrics`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct LineAnnotationExtent { + pub(crate) overflow_over: f64, + pub(crate) overflow_under: f64, + pub(crate) space_over: f64, + pub(crate) space_under: f64, +} + +/// One text item's em box on a line, from the line top, reaching out to +/// its emphasis marks on the side they stand. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ItemAnnotationBox { + pub(crate) over: f64, + pub(crate) under: f64, + pub(crate) marks: Option, +} + +/// The size an emphasis mark is set at: half the text's size rounded to a +/// whole pixel (Blink's `kEmphasisMarkFontSizeMultiplier`, `lroundf`). It +/// is also the mark's height, the mark face's normalized typo ascent and +/// descent summing to its em. +pub fn emphasis_mark_size(font_size: f32) -> f32 { + (font_size * 0.5).round() +} + +/// The annotation extent of a line `height` tall whose baseline sits +/// `baseline` below its top, holding `items`; `font_size` is the line's +/// own (its strut's) size, the content a line with too little text still +/// secures around its centre. +pub(crate) fn line_annotation_extent( + items: &[ItemAnnotationBox], + baseline: f64, + height: f64, + font_size: f64, +) -> LineAnnotationExtent { + let mut content_over = baseline; + let mut content_under = baseline; + let mut marks_over = false; + let mut marks_under = false; + for item in items { + content_over = content_over.min(item.over); + content_under = content_under.max(item.under); + match item.marks { + Some(LineSide::Over) => marks_over = true, + Some(LineSide::Under) => marks_under = true, + None => {} + } + } + if content_under - content_over < font_size { + // LayoutUnit halving: the 1/64 units' difference, divided and + // truncated. + let leading = (height * 64.0).round() - (font_size * 64.0).trunc(); + let half_leading = ((leading / 2.0).trunc() / 64.0).max(0.0); + content_over = half_leading; + content_under = height - half_leading; + } + // Text taller than its line overflows the box only through marks. + if content_over < 0.0 && !marks_over { + content_over = 0.0; + } + if content_under > height && !marks_under { + content_under = height; + } + LineAnnotationExtent { + overflow_over: (-content_over).max(0.0), + overflow_under: (content_under - height).max(0.0), + space_over: content_over.max(0.0), + space_under: (height - content_under).max(0.0), + } +} + +/// Each text item's em box offsets from its baseline, per style and the +/// faces its text was shaped with: one flow's lines mostly repeat them. +#[derive(Default)] +pub(crate) struct EmBoxes { + kept: Vec<(EmBoxKey, Option<(f64, f64)>)>, +} + +/// A text item's style and the faces its text was shaped with. +type EmBoxKey = (StyleId, Vec); + +impl ParleyInlineContext { + /// The em boxes of a line's text items, from the line top, each + /// reaching out to its emphasis marks: an item's baseline is the + /// line's `baseline` less its own shift, its faces those its glyph + /// runs on the line were shaped with. + #[allow(clippy::too_many_arguments)] + pub(crate) fn line_annotation_boxes( + &self, + line: &parley::Line<'_, [u8; 4]>, + items: &[InlineItem], + item_ranges: &[std::ops::Range], + item_shifts: &[f64], + styles: &rito_fragment::FormattingTreeStyles, + baseline: f64, + em_boxes: &mut EmBoxes, + ) -> Vec { + let mut segments: Vec<(usize, Vec)> = Vec::new(); + for run in line.style_runs() { + // Each style run's brush is its item's index. + let parley::LineStyleRun::Glyphs { run, style } = run else { + continue; + }; + let index = u32::from_le_bytes(style.brush) as usize; + if item_ranges.get(index).is_none_or(|range| range.is_empty()) { + continue; + } + let face = self.catalog.face_id(run.font()); + match segments.iter_mut().find(|(item, _)| *item == index) { + Some((_, faces)) if !faces.contains(&face) => faces.push(face), + Some(_) => {} + None => segments.push((index, vec![face])), + } + } + segments + .into_iter() + .filter_map(|(index, mut faces)| { + let InlineItem::Text { style: id, .. } = items.get(index)? else { + return None; + }; + let style = styles.inline.style(*id).ok()?; + faces.sort_unstable(); + let key = (*id, faces); + let offsets = match em_boxes.kept.iter().find(|(kept, _)| *kept == key) { + Some((_, offsets)) => *offsets, + None => { + let offsets = self.em_box_offsets(style, &key.1); + em_boxes.kept.push((key, offsets)); + offsets + } + }?; + let item_baseline = baseline - item_shifts.get(index).copied().unwrap_or(0.0); + let mut over = item_baseline + offsets.0; + let mut under = item_baseline + offsets.1; + let marks = style.text_flow.text_emphasis.as_ref().map(|emphasis| { + let height = f64::from(emphasis_mark_size(style.font.size.get())); + match emphasis.side { + LineSide::Over => over -= height, + LineSide::Under => under += height, + } + emphasis.side + }); + Some(ItemAnnotationBox { over, under, marks }) + }) + .collect() + } + + /// The em box of text in `style` shaped with `faces`, as offsets from + /// its baseline (over negative): its text box drawn in on each side by + /// the least inset of its faces (see [`MeasuredEmphasis::em_insets`]). + pub(crate) fn em_box_offsets( + &self, + style: &InlineFormattingStyle, + faces: &[FontFaceId], + ) -> Option<(f64, f64)> { + let basis = self.em_basis(style)?; + let (over, under) = least_insets( + faces + .iter() + .filter_map(|face| self.em_insets(&basis, *face)), + ); + Some((-basis.above + over, basis.below - under)) + } + + /// What every em box of text in `style` is drawn from. + fn em_basis(&self, style: &InlineFormattingStyle) -> Option { + let size = f64::from(style.font.size.get()); + let baseline = LineBaseline::of(style); + let (ascent, descent, _) = self.primary_face(style)?.rounded(size); + let (above, below) = baseline.split(ascent, descent); + // Around the central baseline the primary ascent compared is half + // the font's height, not the whole-pixel half the text box takes. + let primary_ascent = match baseline { + LineBaseline::Alphabetic => ascent, + LineBaseline::Central => ((ascent + descent) * 32.0).trunc() / 64.0, + }; + Some(EmBasis { + size, + baseline, + above, + below, + primary_ascent, + primary_descent: above + below - primary_ascent, + }) + } + + /// How far `face`'s em box sits inside the text box `basis` gives, over + /// and under: the whole pixels the primary font's ascent and descent + /// exceed the face's normalized typo ascent and descent. + fn em_insets(&self, basis: &EmBasis, face: FontFaceId) -> Option<(f64, f64)> { + let face = self.catalog.face_font(face)?; + let (em_ascent, em_descent) = basis.baseline.em_box(&face.font_ref()?, basis.size); + Some(( + (basis.primary_ascent - em_ascent).max(0.0).floor(), + (basis.primary_descent - em_descent).max(0.0).floor(), + )) + } + + /// The emphasis mark text in `style` takes, measured for painting; + /// `None` when the style takes none or no face serves the mark. + pub fn measure_emphasis_mark(&self, style: &InlineFormattingStyle) -> Option { + use skrifa::MetadataProvider as _; + let emphasis = style.text_flow.text_emphasis.as_ref()?; + // The mark is shaped with the text's font at the text's size; its + // first glyph's face sets it, at the mark's own size. + let run = self.shaped_run_with(style, None, false, None, &emphasis.mark); + let face_id = run.clusters.first()?.face; + let face = self.catalog.face_font(face_id)?; + let font = face.font_ref()?; + let size = f64::from(emphasis_mark_size(style.font.size.get())); + let (ascent, descent) = normalized_typo_height(&font, size); + let character = emphasis.mark.chars().next()?; + let upright = shapes_top_to_bottom(style, character); + let glyph = font.charmap().map(character)?; + let glyph = if upright { + vertical_form(&font, glyph) + } else { + glyph + }; + let ink_center = outline_center(&font, glyph, size)?; + // An upright glyph's blob sits off the point by its face's ascent + // over its central-baseline ascent (half its whole-pixel height). + let (mark_ascent, mark_descent, _) = rounded_metrics(&font, size); + let upright_offset = if upright { + (mark_ascent - mark_descent) / 2.0 + } else { + 0.0 + }; + let basis = self.em_basis(style)?; + let em_insets = (0..self.catalog.aliases().len()) + .filter_map(|index| { + let face = FontFaceId(u32::try_from(index).ok()?); + let (over, under) = self.em_insets(&basis, face)?; + Some((face, over, under)) + }) + .collect(); + Some(MeasuredEmphasis { + mark: emphasis.mark.clone(), + side: emphasis.side, + face: face_id, + size, + ascent, + descent, + ink_center, + upright, + upright_offset, + text_box: (basis.above, basis.below), + em_insets, + }) + } +} + +/// The text box of text in one style and what its em box is measured +/// against: the primary font's whole-pixel ascent and descent from the +/// line's baseline, and its ascent and descent as Blink's +/// `AdjustTextOverUnderOffsetsForEmHeight` compares them. +struct EmBasis { + size: f64, + baseline: LineBaseline, + above: f64, + below: f64, + primary_ascent: f64, + primary_descent: f64, +} + +/// The least of several faces' em insets, over and under; none inset +/// nothing. +fn least_insets(insets: impl Iterator) -> (f64, f64) { + let (over, under) = insets.fold((f64::INFINITY, f64::INFINITY), |(over, under), inset| { + (over.min(inset.0), under.min(inset.1)) + }); + ( + if over.is_finite() { over } else { 0.0 }, + if under.is_finite() { under } else { 0.0 }, + ) +} + +/// The centre of a glyph's outline at `size`, from its origin, y down: +/// the bounds of every point its outline is drawn through, control points +/// included (Skia's path bounds, which Blink centres a mark on). +fn outline_center( + font: &skrifa::FontRef<'_>, + glyph: skrifa::GlyphId, + size: f64, +) -> Option<(f64, f64)> { + use skrifa::outline::{DrawSettings, OutlinePen}; + use skrifa::MetadataProvider as _; + #[derive(Default)] + struct Bounds(Option<[f32; 4]>); + impl Bounds { + fn add(&mut self, x: f32, y: f32) { + let bounds = self.0.get_or_insert([x, y, x, y]); + *bounds = [ + bounds[0].min(x), + bounds[1].min(y), + bounds[2].max(x), + bounds[3].max(y), + ]; + } + } + impl OutlinePen for Bounds { + fn move_to(&mut self, x: f32, y: f32) { + self.add(x, y); + } + fn line_to(&mut self, x: f32, y: f32) { + self.add(x, y); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.add(cx0, cy0); + self.add(x, y); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.add(cx0, cy0); + self.add(cx1, cy1); + self.add(x, y); + } + fn close(&mut self) {} + } + let outline = font.outline_glyphs().get(glyph)?; + let mut bounds = Bounds::default(); + outline + .draw( + DrawSettings::unhinted( + skrifa::instance::Size::new(size as f32), + skrifa::instance::LocationRef::default(), + ), + &mut bounds, + ) + .ok()?; + let [left, bottom, right, top] = bounds.0?; + Some(( + f64::from(left + right) / 2.0, + -f64::from(bottom + top) / 2.0, + )) +} + +/// An emphasis mark measured for painting: the mark, the face and size it +/// is set in, and where it stands from the text it marks. +#[derive(Clone, Debug, PartialEq)] +pub struct MeasuredEmphasis { + /// The mark string; its first glyph is what paints. + pub mark: std::sync::Arc, + /// The side of the line the marks stand on. + pub side: LineSide, + /// The face the mark's first glyph was shaped with. + pub face: FontFaceId, + /// The size the mark is set at. + pub size: f64, + /// The mark face's normalized typo ascent and descent at that size. + pub ascent: f64, + pub descent: f64, + /// The centre of the mark glyph's outline from its origin (y down): + /// its vertical form when it stands upright in a vertical line. + pub ink_center: (f64, f64), + /// Whether the mark stands upright in a vertical line; otherwise it + /// lies with a vertical line's text, turned a quarter. + pub upright: bool, + /// How far towards the line-over side of the point an upright mark's + /// outline centres: its face's whole-pixel ascent less its ascent from + /// the central baseline, half the face's whole-pixel height (Blink's + /// vertical baseline offset of an upright glyph run). Zero otherwise. + pub upright_offset: f64, + /// The marked text's text box from its baseline, (above, below): its + /// primary font's whole-pixel ascent and descent, split around the + /// central baseline in a vertical line. + pub text_box: (f64, f64), + /// Per face the text may be shaped with, how far its em box sits + /// inside the text box, over and under, in whole pixels; text shaped + /// with several faces takes the least of each. + pub em_insets: Vec<(FontFaceId, f64, f64)>, +} + +impl MeasuredEmphasis { + /// How far from the marked text's baseline, towards the side the marks + /// stand on, the point the marks hang from lies for text shaped with + /// `faces`: outside its em box by the mark's typo descent (over) or + /// ascent (under), ceiled to a whole pixel (Blink's + /// `TextPainter::SetEmphasisMark`). A horizontal line's mark sits on + /// that point's baseline; an upright mark in a vertical line centres + /// its outline on it. + pub fn distance(&self, faces: impl Iterator) -> f64 { + let (over, under) = least_insets(faces.filter_map(|face| { + self.em_insets + .iter() + .find(|(inset_face, _, _)| *inset_face == face) + .map(|(_, over, under)| (*over, *under)) + })); + match self.side { + LineSide::Over => self.text_box.0 - over + self.descent.ceil(), + LineSide::Under => self.text_box.1 - under + self.ascent.ceil(), + } + } +} + +/// Whether a character takes an emphasis mark: not a separator, control, +/// format or unassigned character, nor punctuation other than the few +/// symbols CSS lists (Blink's `CanReceiveTextEmphasis`). +pub fn takes_emphasis_mark(character: char) -> bool { + use icu_properties::props::GeneralCategory as Category; + use icu_properties::CodePointMapData; + if matches!( + character, + '\u{1361}' | '\u{10100}' | '\u{10101}' | '\u{1039F}' | '\u{0F0B}' | '\u{0F0C}' + ) { + return false; + } + if matches!( + character, + '#' | '%' + | '&' + | '@' + | '\u{00A7}' + | '\u{00B6}' + | '\u{0609}' + | '\u{060A}' + | '\u{066A}' + | '\u{2030}' + | '\u{2031}' + | '\u{204A}' + | '\u{204B}' + | '\u{2053}' + | '\u{303D}' + | '\u{FE5F}' + | '\u{FE60}' + | '\u{FE6A}' + | '\u{FE6B}' + | '\u{FF03}' + | '\u{FF05}' + | '\u{FF06}' + | '\u{FF20}' + ) { + return true; + } + !matches!( + CodePointMapData::::new().get(character), + Category::SpaceSeparator + | Category::LineSeparator + | Category::ParagraphSeparator + | Category::Unassigned + | Category::Control + | Category::Format + | Category::DashPunctuation + | Category::OpenPunctuation + | Category::ClosePunctuation + | Category::ConnectorPunctuation + | Category::OtherPunctuation + | Category::InitialPunctuation + | Category::FinalPunctuation + ) +} + +#[cfg(test)] +mod tests { + use super::{emphasis_mark_size, line_annotation_extent, ItemAnnotationBox}; + use rito_style_contract::LineSide; + + #[test] + fn a_mark_is_half_the_text_rounded_to_a_whole_pixel() { + assert_eq!(emphasis_mark_size(16.0), 8.0); + assert_eq!(emphasis_mark_size(15.0), 8.0); + assert_eq!(emphasis_mark_size(15.2), 8.0); + assert_eq!(emphasis_mark_size(13.0), 7.0); + } + + /// The 050 paragraph: a 20.796875px line, its baseline 16 below the + /// top, the text's em box from 1 to 18, dots under it 8 tall. + #[test] + fn marks_under_a_short_line_overflow_its_bottom() { + let item = |marks| ItemAnnotationBox { + over: 1.0, + under: 18.0 + + if marks == Some(LineSide::Under) { + 8.0 + } else { + 0.0 + }, + marks, + }; + let marked = line_annotation_extent(&[item(Some(LineSide::Under))], 16.0, 20.796875, 16.0); + assert_eq!(marked.overflow_under, 26.0 - 20.796875); + assert_eq!(marked.space_over, 1.0); + let plain = line_annotation_extent(&[item(None)], 16.0, 20.796875, 16.0); + assert_eq!(plain.overflow_under, 0.0); + assert_eq!(plain.space_under, 20.796875 - 18.0); + } + + /// A text box taller than its line overflows nothing without marks. + #[test] + fn text_alone_never_overflows() { + let item = ItemAnnotationBox { + over: -2.0, + under: 21.0, + marks: None, + }; + let extent = line_annotation_extent(&[item], 16.0, 20.0, 16.0); + assert_eq!(extent.overflow_over, 0.0); + assert_eq!(extent.overflow_under, 0.0); + assert_eq!(extent.space_over, 0.0); + } +} diff --git a/crates/rito-inline/src/fonts.rs b/crates/rito-inline/src/fonts.rs index 593c32bb5..07cdaa56f 100644 --- a/crates/rito-inline/src/fonts.rs +++ b/crates/rito-inline/src/fonts.rs @@ -18,6 +18,7 @@ //! same name, and the resolved list is handed to shaping and to every //! metric path alike. +use crate::Face; use std::cell::RefCell; use std::collections::BTreeSet; @@ -124,6 +125,8 @@ type StackCache = RefCell< pub(crate) struct FontCatalog { families: Vec, aliases: Vec, + /// The font each face id names: the first its blob registered. + face_fonts: Vec, face_ids: FxHashMap<(u64, u32), FontFaceId>, stacks: StackCache, /// The number each distinct stack key was given, in order of first use. @@ -135,9 +138,10 @@ pub(crate) struct FontCatalog { /// The advance every glyph of a face declares in its `hmtx` table, in /// font units, per (blob id, face index); read once per face. declared_advances: RefCell>>, - /// Whether a face declares the OpenType `halt` feature, per (blob id, - /// face index); its feature lists are read once per face. - halt: RefCell>, + /// Whether a face declares the OpenType `halt` feature (`vhal` for + /// vertical), per (blob id, face index, vertical); its feature lists + /// are read once per face. + halt: RefCell>, } /// A face's `hmtx` advances in font units, indexed by glyph id. @@ -238,11 +242,17 @@ impl FontCatalog { } let id = FontFaceId(self.aliases.len() as u32); self.aliases.push(alias); + let mut first = None; for (_, fonts) in registered { for font in fonts { + first.get_or_insert(font.index()); self.face_ids.insert((blob.id(), font.index()), id); } } + self.face_fonts.push(Face { + blob: blob.clone(), + index: first.unwrap_or_default(), + }); self.stacks.get_mut().clear(); Ok(()) } @@ -278,6 +288,11 @@ impl FontCatalog { &self.aliases } + /// The font a face id names. + pub(crate) fn face_font(&self, id: FontFaceId) -> Option { + self.face_fonts.get(id.0 as usize).cloned() + } + /// Names the catalog could not resolve since it was built, lowercase. pub(crate) fn unknown_families(&self) -> Vec { self.unknown.borrow().iter().cloned().collect() @@ -315,14 +330,16 @@ impl FontCatalog { units } - /// Whether `font` declares the OpenType `halt` feature in GSUB or GPOS. - pub(crate) fn has_halt(&self, font: &parley::FontData) -> bool { - let key = (font.data.id(), font.index); + /// Whether `font` declares its alternate half widths, the OpenType + /// `halt` feature (`vhal` for a run shaped top to bottom), in GSUB or + /// GPOS. + pub(crate) fn has_halt(&self, font: &parley::FontData, vertical: bool) -> bool { + let key = (font.data.id(), font.index, vertical); if let Some(halt) = self.halt.borrow().get(&key) { return *halt; } let halt = skrifa::FontRef::from_index(font.data.as_ref(), font.index) - .is_ok_and(|face| crate::punctuation::font_ref_has_halt(&face)); + .is_ok_and(|face| crate::punctuation::font_ref_has_halt(&face, vertical)); self.halt.borrow_mut().insert(key, halt); halt } diff --git a/crates/rito-inline/src/image.rs b/crates/rito-inline/src/image.rs index e69d216aa..6a5bef7a1 100644 --- a/crates/rito-inline/src/image.rs +++ b/crates/rito-inline/src/image.rs @@ -182,11 +182,19 @@ pub(crate) fn image_display_size( // percentage binds only against a definite containing height. The // clamp rescales only the AUTO cross axis, like the max-width arm. let max_height_cap = match layout_style.max_height { - rito_style_contract::MaximumHeight::None => None, - rito_style_contract::MaximumHeight::Length(px) => Some(f64::from(px.get())), - rito_style_contract::MaximumHeight::Percentage(ratio) => { - containing_block_size.map(|basis| f64::from(ratio.ratio()) * basis) - } + rito_style_contract::MaximumSize::Value(value) => match value.value() { + rito_style_contract::LengthPercentage::Length(px) => Some(f64::from(px.get())), + rito_style_contract::LengthPercentage::Percentage(ratio) => { + containing_block_size.map(|basis| f64::from(ratio.ratio()) * basis) + } + rito_style_contract::LengthPercentage::Linear { length, percentage } => { + containing_block_size + .map(|basis| f64::from(length.get()) + f64::from(percentage.ratio()) * basis) + } + }, + // The intrinsic keywords have no used-value calculation here: the + // cap is omitted, as `max-width`'s are. + _ => None, }; if let Some(cap) = max_height_cap { if height > cap && height > 0.0 { diff --git a/crates/rito-inline/src/inline_block.rs b/crates/rito-inline/src/inline_block.rs new file mode 100644 index 000000000..124474c3f --- /dev/null +++ b/crates/rito-inline/src/inline_block.rs @@ -0,0 +1,371 @@ +//! Inline blocks on a line: an atomic inline sized from its own style (CSS +//! 2.1 §10.3.9 and §10.6.6, as Blink computes an atomic inline's fragment +//! size), its content laid out at that size — a mini paragraph by this +//! context, a block container by the block context that owns it — and the +//! baseline it rides the line by. + +use rito_style_contract::{ + BoxSizing, LayoutStyleId, LengthPercentageOrAuto, MinimumSize, NonNegativeLengthPercentage, + Overflow, +}; + +use crate::atoms::SizedAtom; +use crate::*; + +impl ParleyInlineContext { + /// The inline block `node`, styled `layout_style`, sized and laid out + /// for a line of the paragraph at `host` in `space`. + pub(crate) fn size_inline_block( + &self, + tree: &FormattingTree, + host: FormattingNodeId, + node: FormattingNodeId, + layout_style: LayoutStyleId, + space: &BuildSpace, + cancel: &CancelFlag, + ) -> Result { + let styles = tree.styles().ok_or_else(|| { + LayoutError::Invalid("inline flow tree carries no style tables".to_owned()) + })?; + let style = styles + .layout + .style(layout_style) + .map_err(|error| LayoutError::Invalid(error.to_string()))?; + // One in a writing mode not the host's is an orthogonal flow: its + // lines run across the host's, in the space the host has across + // them (CSS Writing Modes 3 §7.3). + let horizontal = |mode: WritingMode| mode == WritingMode::HorizontalTopToBottom; + let orthogonal = tree + .strut_style(host) + .and_then(|id| styles.inline.style(id).ok()) + .is_some_and(|strut| { + horizontal(strut.bidi.writing_mode) != horizontal(style.writing_mode) + }); + let block_content = matches!( + tree.node(node).content, + FormattingNodeContent::BlockContainer + ); + // Its margins, borders and padding surround the content on the + // line (Blink sizes an atomic inline by its margin box); an + // orthogonal flow keeps its bare box. + let (margin, frame) = if orthogonal { + (BoxEdges::default(), BoxEdges::default()) + } else { + box_edges(tree, host, layout_style, space.inline_size())? + }; + // Percentages along its own inline axis resolve against the host's + // lines, or, orthogonal, against the host's block size: the + // initial containing block's when that is indefinite (Blink's + // orthogonal fallback inline size), and the page is the reader's. + let (inline_basis, block_basis) = if orthogonal { + ( + space.containing_block_size().or(space.block_size()), + space.inline_size(), + ) + } else { + (space.inline_size(), space.containing_block_size()) + }; + let frame_inline = frame.inline_start + frame.inline_end; + let frame_block = frame.block_start + frame.block_end; + let inline_bounds = SizeBounds::new( + style.inline_size(), + style.min_inline_size(), + style.max_inline_size(), + style.box_sizing, + inline_basis, + frame_inline, + ); + let content_inline = match inline_bounds.preferred { + Some(size) => size, + None => { + // Shrink-to-fit (CSS 2.1 §10.3.5). + let sizes = self.inline_block_content_sizes(tree, node, block_content)?; + let available = if orthogonal { + inline_basis + } else { + space.inline_size().map(|size| { + (size - margin.inline_start - margin.inline_end - frame_inline).max(0.0) + }) + } + .unwrap_or(sizes.max_content); + sizes.max_content.min(sizes.min_content.max(available)) + } + }; + let content_inline = inline_bounds.clamp(content_inline); + let mut root_box = if block_content { + let delegate = self.block_layout.ok_or_else(|| { + LayoutError::Invalid( + "an inline-block holding blocks needs the block layout of its block context" + .to_owned(), + ) + })?; + // The block container lays its content inside its own padding + // (its borders folded in) at its border-box size. + let outcome = (delegate.layout)( + self, + tree, + node, + &ConstraintSpace::continuous(content_inline + frame_inline), + cancel, + )?; + let Fragment::Box(root_box) = outcome.fragments.root else { + return Err(LayoutError::Invalid( + "inline-block layout must produce a box fragment".to_owned(), + )); + }; + root_box + } else { + let outcome = FormattingContext::layout( + self, + tree, + node, + &ConstraintSpace::continuous(content_inline), + None, + cancel, + )?; + let Fragment::Box(mut root_box) = outcome.fragments.root else { + return Err(LayoutError::Invalid( + "inline-block layout must produce a box fragment".to_owned(), + )); + }; + shift_children(&mut root_box, frame.inline_start, frame.block_start); + root_box.rect.width += frame_inline; + root_box.rect.height += frame_block; + root_box + }; + let block_bounds = SizeBounds::new( + style.block_size(), + style.min_block_size(), + style.max_block_size(), + style.box_sizing, + block_basis, + frame_block, + ); + let content_block = block_bounds.clamp( + block_bounds + .preferred + .unwrap_or(root_box.rect.height - frame_block), + ); + root_box.rect.height = content_block + frame_block; + // The atom is its margin box. + shift_children(&mut root_box, margin.inline_start, margin.block_start); + root_box.rect.width += margin.inline_start + margin.inline_end; + root_box.rect.height += margin.block_start + margin.block_end; + if orthogonal { + // Across the host's lines it advances by its block size and + // stands as wide as its lines run; it has no baseline in the + // host's direction, so it hangs from one synthesized off its + // margin box (`LogicalBoxFragment::BaselineMetrics`). + let (above, _) = crate::layout::atom_baseline_extent(root_box.rect.width, true); + return Ok(SizedAtom { + width: root_box.rect.height as f32, + height: root_box.rect.width as f32, + edge_insets: None, + block: Some((node.0, above, root_box)), + }); + } + // Its baseline is its last line box's (CSS 2.1 §10.8.1), however + // deep in its block content; one with no line box, or a scroll + // container (Blink counts `overflow: hidden` as one), rides on its + // bottom margin edge. + let baseline = (style.overflow == Overflow::Visible) + .then(|| last_line_baseline(tree, &root_box)) + .flatten() + .unwrap_or(root_box.rect.height); + Ok(SizedAtom { + width: root_box.rect.width as f32, + height: root_box.rect.height as f32, + edge_insets: None, + block: Some((node.0, baseline, root_box)), + }) + } + + /// The min- and max-content inline sizes of an inline block's content: + /// its mini paragraph's, or the widest of its block children's + /// contributions. + fn inline_block_content_sizes( + &self, + tree: &FormattingTree, + node: FormattingNodeId, + block_content: bool, + ) -> Result { + if !block_content { + return self.intrinsic_inline_sizes(tree, node); + } + let delegate = self.block_layout.ok_or_else(|| { + LayoutError::Invalid( + "an inline-block holding blocks needs the block layout of its block context" + .to_owned(), + ) + })?; + let mut sizes = IntrinsicInlineSizes { + min_content: 0.0, + max_content: 0.0, + }; + for child in &tree.node(node).children { + let child_sizes = (delegate.intrinsic_inline_sizes)(self, tree, *child)?; + sizes.min_content = sizes.min_content.max(child_sizes.min_content); + sizes.max_content = sizes.max_content.max(child_sizes.max_content); + } + Ok(sizes) + } +} + +/// A box's specified size along one of its own axes and the bounds on it, +/// as content-box lengths; a percentage against no basis is `auto`. +struct SizeBounds { + preferred: Option, + min: f64, + max: f64, +} + +impl SizeBounds { + fn new( + preferred: PreferredSize, + min: MinimumSize, + max: MaximumSize, + sizing: BoxSizing, + basis: Option, + frame: f64, + ) -> Self { + // A used size sits on the LayoutUnit grid by truncation. + let content = |used: f64| { + let used = (used * 64.0).trunc() / 64.0; + match sizing { + BoxSizing::ContentBox => used, + BoxSizing::BorderBox => used - frame, + } + .max(0.0) + }; + let resolve = |value: NonNegativeLengthPercentage| match value.value() { + LengthPercentage::Length(px) => Some(content(f64::from(px.get()))), + LengthPercentage::Percentage(ratio) => { + basis.map(|basis| content(f64::from(ratio.ratio()) * basis)) + } + LengthPercentage::Linear { length, percentage } => basis.map(|basis| { + content(f64::from(length.get()) + f64::from(percentage.ratio()) * basis) + }), + }; + Self { + preferred: match preferred { + PreferredSize::Value(value) => resolve(value), + _ => None, + }, + min: match min { + MinimumSize::Length(px) => content(f64::from(px.get())), + MinimumSize::Percentage(ratio) => basis + .map(|basis| content(f64::from(ratio.ratio()) * basis)) + .unwrap_or(0.0), + MinimumSize::Auto => 0.0, + }, + max: match max { + MaximumSize::Value(value) => resolve(value).unwrap_or(f64::INFINITY), + _ => f64::INFINITY, + }, + } + } + + /// `size` held within the bounds; the minimum wins over the maximum. + fn clamp(&self, size: f64) -> f64 { + size.min(self.max).max(self.min) + } +} + +/// Moves every child of `root` by (`inline`, `block`). +fn shift_children(root: &mut BoxFragment, inline: f64, block: f64) { + for child in &mut root.children { + let rect = child.rect_mut(); + rect.x += inline; + rect.y += block; + } +} + +/// Where the last line box in `root`'s flow sits, its baseline from the +/// box's block-start edge: searched through in-flow block children, past +/// floats and absolutely positioned boxes. +fn last_line_baseline(tree: &FormattingTree, root: &BoxFragment) -> Option { + let out_of_flow = |source: FormattingNodeId| { + tree.styles() + .and_then(|styles| styles.layout.style(tree.node(source).style).ok()) + .is_some_and(|style| { + style.float != rito_style_contract::Float::None + || style.position == rito_style_contract::Position::Absolute + }) + }; + root.children.iter().rev().find_map(|child| match child { + Fragment::Line(line) => Some(line.rect.y + line.baseline), + Fragment::Box(inner) if !out_of_flow(inner.source) => { + last_line_baseline(tree, inner).map(|baseline| inner.rect.y + baseline) + } + _ => None, + }) +} + +/// An inline block's margin, or its border plus padding, on each side of +/// its content, in the host's flow-relative directions. +#[derive(Clone, Copy, Debug, Default)] +struct BoxEdges { + inline_start: f64, + inline_end: f64, + block_start: f64, + block_end: f64, +} + +/// The margins and the borders plus paddings of the inline block styled +/// `layout_style` on a line of the paragraph at `host`, percentages +/// against the host's inline size, each truncated onto the LayoutUnit grid +/// as Blink stores a used margin or padding. +fn box_edges( + tree: &FormattingTree, + host: FormattingNodeId, + layout_style: LayoutStyleId, + containing_inline_size: Option, +) -> Result<(BoxEdges, BoxEdges), LayoutError> { + let styles = tree.styles().ok_or_else(|| { + LayoutError::Invalid("inline flow tree carries no style tables".to_owned()) + })?; + let style_of = |id| { + styles + .layout + .style(id) + .map_err(|error| LayoutError::Invalid(error.to_string())) + }; + let style = style_of(layout_style)?; + let container = style_of(tree.node(host).style)?.writing_direction(); + let basis = containing_inline_size.unwrap_or(0.0); + let used = |value: LengthPercentage| { + let resolved = match value { + LengthPercentage::Length(px) => f64::from(px.get()), + LengthPercentage::Percentage(ratio) => f64::from(ratio.ratio()) * basis, + LengthPercentage::Linear { length, percentage } => { + f64::from(length.get()) + f64::from(percentage.ratio()) * basis + } + }; + (resolved * 64.0).trunc() / 64.0 + }; + let margins = style.margin_in(container); + let margin = |side: LengthPercentageOrAuto| match side { + LengthPercentageOrAuto::Auto => 0.0, + LengthPercentageOrAuto::Value(value) => used(value), + }; + let padding = style.flow_padding(); + let border = style.flow_border_widths(); + let frame = |border_side: rito_style_contract::NonNegativeCssPx, + padding_side: NonNegativeLengthPercentage| { + f64::from(border_side.get()) + used(padding_side.value()).max(0.0) + }; + Ok(( + BoxEdges { + inline_start: margin(margins.inline_start), + inline_end: margin(margins.inline_end), + block_start: margin(margins.block_start), + block_end: margin(margins.block_end), + }, + BoxEdges { + inline_start: frame(border.inline_start, padding.inline_start), + inline_end: frame(border.inline_end, padding.inline_end), + block_start: frame(border.block_start, padding.block_start), + block_end: frame(border.block_end, padding.block_end), + }, + )) +} diff --git a/crates/rito-inline/src/justify.rs b/crates/rito-inline/src/justify.rs index 18d0076ce..2fd336a3b 100644 --- a/crates/rito-inline/src/justify.rs +++ b/crates/rito-inline/src/justify.rs @@ -41,10 +41,12 @@ pub(crate) fn is_cjk_justify(character: char) -> bool { | 0x20000..=0x2FA1F) } -/// Whether a justified line expands after this character: word separators -/// and CJK ideographs/symbols both open a share on their right side. +/// Whether a justified line expands after this character: what Blink +/// treats as a space (`unicode::TreatAsSpace`: the space, the tab, the +/// line feed and the no-break space) and CJK ideographs/symbols both open +/// a share on their right side. pub(crate) fn justify_expands_after(character: char) -> bool { - character == ' ' || is_cjk_justify(character) + matches!(character, ' ' | '\t' | '\n' | '\u{A0}') || is_cjk_justify(character) } /// Punctuation that CJK fonts join into one continuous rule when repeated @@ -140,12 +142,17 @@ pub(crate) fn line_justify_plan( slack: f64, spread_ranges: &[std::ops::Range], atom_positions: &[usize], + combined_ranges: &[std::ops::Range], ) -> Option { // Bails on NaN slack too: only a strictly positive slack justifies. if slack.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { return None; } - let content = text.get(range.clone())?.trim_end(); + // The spaces that hang past the line's end take no share; a no-break + // space does not hang. + let content = text + .get(range.clone())? + .trim_end_matches(|character: char| character.is_whitespace() && character != '\u{A0}'); let content_end = range.start + content.len(); let mut counts: Vec<(usize, u32)> = Vec::new(); let mut before_bytes: Vec = Vec::new(); @@ -220,6 +227,17 @@ pub(crate) fn line_justify_plan( ) { continue; } + // A combined upright unit justifies as one ideograph, Blink's + // kTextCombineItemMarker (U+3042): its first character stands for + // it and the boundaries inside it are no boundaries. + let character = match combined_ranges + .iter() + .find(|unit| unit.contains(&(range.start + offset))) + { + Some(unit) if unit.start != range.start + offset => continue, + Some(_) => '\u{3042}', + None => character, + }; // A spread ruby base (its annotation wider than it) is one // justification item, Chromium's kBaseShorterRubyMarker: no // interior expansion (its clusters already sit at the @@ -306,3 +324,44 @@ pub(crate) fn line_justify_plan( total, }) } + +/// How much wider than its content Blink takes a line to be when it aligns +/// it, because of the rubies on it: a line the breaker ended by rewinding to +/// an item boundary (the next item's first glyph did not fit) has its width +/// summed again from its items' inline sizes (`LineBreaker::Rewind` → +/// `LineInfo::ComputeWidth`), where a ruby column counts whole and its +/// overhangs, kept inside the column's base line, are lost. Such a line +/// aligns as if that much wider: one it then fills is not justified. +/// Measured: a 300px justified line holding 碎片/Fragment (7px overhang +/// each side) and ending at a `` stays unexpanded at 287.95. +pub(crate) fn rewound_ruby_overhangs( + line: std::ops::Range, + break_reason: parley::layout::BreakReason, + items: &[std::ops::Range], + overhangs_left: &std::collections::HashMap, + overhangs_right: &std::collections::HashMap, +) -> f64 { + if break_reason != parley::layout::BreakReason::Regular { + return 0.0; + } + let ends_at_item = items + .iter() + .any(|range| !range.is_empty() && range.end == line.end) + && items + .iter() + .any(|range| !range.is_empty() && range.start >= line.end); + if !ends_at_item { + return 0.0; + } + items + .iter() + .enumerate() + .filter(|(_, range)| { + !range.is_empty() && range.start >= line.start && range.end <= line.end + }) + .map(|(item, _)| { + overhangs_left.get(&item).copied().unwrap_or(0.0) + + overhangs_right.get(&item).copied().unwrap_or(0.0) + }) + .sum() +} diff --git a/crates/rito-inline/src/layout.rs b/crates/rito-inline/src/layout.rs index 49e4c6f59..2c02ecb2a 100644 --- a/crates/rito-inline/src/layout.rs +++ b/crates/rito-inline/src/layout.rs @@ -36,83 +36,7 @@ impl FormattingContext for ParleyInlineContext { let band = space .float_band .filter(|band| band.bottom > 0.0 && (band.left_inset > 0.0 || band.right_inset > 0.0)); - // Lines break through one manual loop so a specific line index can - // be FORCED to hold an exact cluster count: the browser's rejected - // line-end trim extension rewinds the whole overflowing item to - // the next line (measured: a razor-fit note line breaks as - // [span ①][whole text item] where greedy would split the text), - // and `break_next_with_length` reproduces that rewind. - // Blink accepts a line that overflows its available width by up to - // one LayoutUnit: NGLineBreaker::CanFitOnLine compares against - // available_width_.AddEpsilon(). Measured on the Tinos body idiom: - // a 626.695-wide line fits a 626.6875 column (threshold scanned to - // the 1/64), so a strict compare here wrapped one word early and - // drifted whole paragraphs. The epsilon widens only the FIT — the - // justify target below keeps the true width, exactly as Blink - // justifies to the unwidened line box. - const LINE_FIT_EPSILON: f64 = 1.0 / 64.0; - let break_lines = |layout: &mut parley::Layout<[u8; 4]>, forced: &[(usize, u32)]| { - if band.is_none() && forced.is_empty() { - layout.break_all_lines(Some((space.inline_size + LINE_FIT_EPSILON) as f32)); - return; - } - let mut breaker = layout.break_lines(); - breaker - .state_mut() - .set_layout_max_advance((space.inline_size + LINE_FIT_EPSILON) as f32); - let mut index = 0usize; - loop { - // Every line gets its advance set explicitly: a forced - // break leaves breaker state the next natural break must - // not inherit. - if let Some(band) = band { - let band_inline_size = - (space.inline_size - band.left_inset - band.right_inset).max(0.0); - let inside = f64::from(breaker.committed_y() as f32) < band.bottom; - let (advance, offset) = if inside { - (band_inline_size, band.left_inset) - } else { - (space.inline_size, 0.0) - }; - let state = breaker.state_mut(); - state.set_line_max_advance((advance + LINE_FIT_EPSILON) as f32); - state.set_line_x(offset as f32); - } else { - breaker - .state_mut() - .set_line_max_advance((space.inline_size + LINE_FIT_EPSILON) as f32); - } - let forced_count = forced - .iter() - .find(|(line, _)| *line == index) - .map(|(_, count)| *count); - let progressed = match forced_count { - Some(count) => breaker.break_next_with_length(count).is_some(), - None => breaker.break_next().is_some(), - }; - if !progressed { - break; - } - index += 1; - } - breaker.finish(); - }; - // The max advance the breaker gave a line, reconstructed from the - // same rule the loop above applied: band width while the line's top - // sits inside the band, the full inline size below it. - let line_max_advance = |line_top: f64| match band { - Some(band) if line_top < band.bottom => { - (space.inline_size - band.left_inset - band.right_inset).max(0.0) as f32 - } - _ => space.inline_size as f32, - }; - // Conditional line-end trim, to a fixpoint: find the first soft - // break Chromium would have extended past a trimmed closing glyph, - // apply that trim, re-lay, and keep it only if the line then breaks - // exactly after the trimmed closer — parley's own fitting and break - // rules stay the authority over what an accepted trim produces. - // Each accepted trim finalizes one more line, so the loop is - // bounded by the line count (plus one rebuild per rejection). + let line_max_advance = |line_top: f64| line_max_advance(space, band, line_top); // Byte range each item occupies in the flow text (images occupy // none). Hoisted above the layout loop: the rewind detection maps // an overflowing character back to its item. @@ -129,7 +53,9 @@ impl FormattingContext for ParleyInlineContext { } InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => cursor..cursor, + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => cursor..cursor, }) .collect() } @@ -154,24 +80,44 @@ impl FormattingContext for ParleyInlineContext { .collect(), _ => Vec::new(), }; + // Flow-text ranges of combined upright units, each of which + // justifies as one ideograph. + let combined_ranges: Vec> = + match (&tree.node(root).content, tree.styles()) { + (FormattingNodeContent::InlineFlow { items }, Some(styles)) => items + .iter() + .zip(item_text_ranges.iter()) + .filter(|(item, range)| { + !range.is_empty() + && matches!(item, InlineItem::Text { style, .. } + if styles.inline.style(*style).is_ok_and(combines_upright)) + }) + .map(|(_, range)| range.clone()) + .collect(), + _ => Vec::new(), + }; // The inline sizes every break decision below answers alike at. let mut bounds = WidthBounds::new(space.inline_size); - let mut end_trims: Vec = Vec::new(); - let mut rejected_trims: Vec = Vec::new(); - let mut suppressed_pair_trims: Vec = Vec::new(); - let mut forced_line_breaks: Vec<(usize, u32)> = Vec::new(); - let mut pending_trim: Option = None; - let ( + let decided = self.decide_line_edges( + &LineEdgeInputs { + tree, + root, + space, + band, + item_text_ranges: &item_text_ranges, + cancel, + }, + &mut bounds, + )?; + let DecidedParagraph { layout, alignment, shifted_ranges, first_line_indent, - item_box_sheds, - forced_line_indents, ruby_spreads, ruby_spread_overhangs, ruby_spread_overhangs_right, - ruby_center_shifts, + ruby_base_shifts, opener_halt_trims, mut inline_block_boxes, inline_block_baselines, @@ -179,516 +125,7 @@ impl FormattingContext for ParleyInlineContext { empty_box_struts, spacing_edits, cluster_steps, - ) = { - let mut split_spread_edits: Vec<(std::ops::Range, f32)> = Vec::new(); - let mut split_spread_rounds = 0u32; - loop { - if cancel.is_cancelled() { - return Err(LayoutError::Cancelled); - } - let ParagraphLayout { - mut layout, - alignment, - shifted_ranges, - text, - spacing_edits, - first_line_indent, - pair_trims, - opener_halt_trims, - item_box_sheds, - forced_line_indents, - ruby_spreads, - ruby_spread_overhangs, - ruby_spread_overhangs_right, - ruby_annotation_widths, - ruby_center_shifts, - inline_block_boxes, - inline_block_baselines, - image_edge_insets, - empty_box_struts, - cluster_steps, - line_end_trims, - kept_breaks, - reads_space, - } = self.shaped_paragraph( - tree, - root, - space, - ParagraphEdits { - end_trims: &end_trims, - suppressed_pair_trims: &suppressed_pair_trims, - split_spreads: &split_spread_edits, - }, - cancel, - )?; - // A copy of this build broken alike before takes its lines - // back rather than breaking again. - kept_breaks.break_or_restore( - &mut layout, - BreakKey::new(space, band, &forced_line_breaks), - |layout| break_lines(layout, &forced_line_breaks), - ); - if reads_space { - bounds.pin(); - } - // Every line's fit, against the max advance the breaker gave - // it: the inline size plus the fit tolerance, less a float - // band's insets inside the band and the indent on the first - // line. - let mut line_top = 0.0_f64; - for (index, line) in layout.lines().enumerate() { - let indent = if index == 0 { first_line_indent } else { 0.0 }; - let inside_band = band.is_some_and(|band| line_top < band.bottom); - if let (true, Some(band)) = (inside_band, band) { - if space.inline_size - band.left_inset - band.right_inset <= 0.0 { - bounds.pin(); - } - } - let limit = - (f64::from(line_max_advance(line_top)) + LINE_FIT_EPSILON) as f32 - indent; - let (floor, ceiling) = line.fit_range(); - bounds.fit_range(f64::from(floor), f64::from(ceiling), f64::from(limit)); - line_top += f64::from(line.metrics().line_height); - } - // A pair trim is only real while both glyphs share a line - // (measured: a line-final comma keeps its full width when its - // partner bracket opens the next line, and the line's justify - // slack follows). Suppress every straddled pair and re-lay to - // a fixpoint; suppression only widens lines, so breaks only - // move earlier and the loop is bounded by the pair count. - { - let mut straddled = false; - for (left_byte, right_byte) in &pair_trims { - if layout - .lines() - .any(|line| line.text_range().start == *right_byte) - && !suppressed_pair_trims.contains(left_byte) - { - suppressed_pair_trims.push(*left_byte); - straddled = true; - } - } - if straddled { - forced_line_breaks.clear(); - continue; - } - } - if let Some(byte) = pending_trim.take() { - let trimmed_end = byte + text[byte..].chars().next().map_or(1, char::len_utf8); - let confirmed = layout - .lines() - .any(|line| line.text_range().end == trimmed_end); - if !confirmed { - end_trims.retain(|&trim| trim != byte); - rejected_trims.push(byte); - continue; - } - } - // Ruby split-fit, before any trim reasoning: a soft break - // inside a ruby base is legal only while the first segment - // still fits carrying its WHOLE annotation — the segment - // widens to at least the annotation's advance (measured: - // 608px of text plus 异 at 16px fit a 627.2px line, but the - // segment carries Talent at 23px and Blink sends the ruby - // down; 黄金妖|精 stays split because 黄金妖 at 48px covers - // Leprechaun's 44px). An overflowing split rewinds the line - // to the item start and re-lays. - if !ruby_annotation_widths.is_empty() { - let mut rewound_ruby = false; - let mut line_top = 0.0_f64; - for index in 0..layout.len().saturating_sub(1) { - let indent = if index == 0 { first_line_indent } else { 0.0 }; - let max_advance = - f64::from(line_max_advance(line_top) - indent) + LINE_FIT_EPSILON; - line_top += layout - .get(index) - .map_or(0.0, |line| f64::from(line.metrics().line_height)); - if forced_line_breaks.iter().any(|(line, _)| *line == index) { - continue; - } - let Some(line) = layout.get(index) else { - continue; - }; - if line.break_reason() != parley::layout::BreakReason::Regular { - continue; - } - let range = line.text_range(); - let Some((item_start, annotation_width)) = item_text_ranges - .iter() - .enumerate() - .find_map(|(item, item_range)| { - (item_range.start < range.end && range.end < item_range.end) - .then(|| { - ruby_annotation_widths - .get(&item) - .map(|width| (item_range.start, *width)) - }) - .flatten() - }) - else { - continue; - }; - // The split's first segment must START on this line; - // a base already split earlier has nothing to rewind. - if item_start < range.start { - continue; - } - let mut segment_advance = 0.0_f64; - let mut cluster = - parley::layout::Cluster::from_byte_index(&layout, item_start); - while let Some(current) = cluster { - if current.text_range().start >= range.end { - break; - } - segment_advance += f64::from(current.advance()); - cluster = current.next_logical(); - } - // A multi-word annotation splits at its spaces and - // only the words allocated to THIS segment (by - // character-midpoint position) must fit over it — - // 正|规勇者 under "Legal Brave" keeps the split - // because 正 carries only Legal (measured matrix). - let segment_annotation_width = { - let item_index = item_text_ranges - .iter() - .position(|candidate| candidate.start == item_start); - let annotation = - item_index.and_then(|index| match &tree.node(root).content { - FormattingNodeContent::InlineFlow { items } => { - match items.get(index) { - Some(InlineItem::Text { - ruby_annotation: Some(annotation), - .. - }) => Some(annotation.text.clone()), - _ => None, - } - } - _ => None, - }); - let item_range = - item_index.map(|index| item_text_ranges[index].clone()); - let total_chars = item_range - .as_ref() - .and_then(|item| text.get(item.clone())) - .map_or(0, |base| base.chars().count()); - let segment_chars = text - .get(item_start..range.end) - .map_or(0, |segment| segment.chars().count()); - match annotation { - Some(annotation) if total_chars > 0 => { - let ratio = segment_chars as f64 / total_chars as f64; - let allocated = rito_fragment::allocate_ruby_annotation( - &annotation, - 0.0, - ratio, - ); - if allocated == annotation { - annotation_width - } else if allocated.is_empty() { - 0.0 - } else { - // Approximate the allocated words' - // advance by character share — exact - // enough for the fit decision, and - // both ends stay measurement-free. - annotation_width * allocated.chars().count() as f64 - / annotation.chars().count().max(1) as f64 - } - } - _ => annotation_width, - } - }; - // The segment's box presses its allocated - // annotation's advance on the line, minus the RUBY's - // own spread overhang — an unspread ruby (annotation - // narrower than the whole base) overhangs nothing, so - // its full allocated advance presses (measured: - // 异/Talent rewinds at 19.875 where the segment-local - // half-excess would have squeaked by; spread - // 咒/Thaumaturgy keeps its split at 42.95 − 2.74). - let segment_box = { - let overhang = item_text_ranges - .iter() - .position(|candidate| candidate.start == item_start) - .and_then(|index| ruby_spread_overhangs.get(&index)) - .copied() - .unwrap_or(0.0); - (segment_annotation_width - overhang).max(segment_advance) - }; - if segment_box <= segment_advance + LINE_FIT_EPSILON { - continue; - } - let metrics = line.metrics(); - let natural = - f64::from(metrics.advance) - f64::from(metrics.trailing_whitespace); - if bounds.fits(natural - segment_advance + segment_box, max_advance) { - continue; - } - if item_start <= range.start { - continue; - } - let Some(count) = text - .get(range.start..item_start) - .map(|held| held.chars().count()) - .filter(|count| *count > 0) - .and_then(|count| u32::try_from(count).ok()) - else { - continue; - }; - forced_line_breaks.push((index, count)); - rewound_ruby = true; - break; - } - if rewound_ruby { - continue; - } - } - // A ruby base SPLIT across lines spreads each on-line - // segment to its allocated annotation words' advance - // (measured on a walk-mirrored page: 'Leprechaun' over the - // split 黄金|妖精 spreads the first segment's 黄 to a - // 21.77px box — 16 + excess/(2·n) space-around shares — - // while the whole-base test saw a narrow annotation and - // left it packed). Once per layout round, capped at two - // rounds: the widening itself can move the break. - if split_spread_rounds < 2 && !ruby_annotation_widths.is_empty() { - let mut new_edits: Vec<(std::ops::Range, f32)> = Vec::new(); - for index in 0..layout.len() { - let Some(line) = layout.get(index) else { - continue; - }; - let line_range = line.text_range(); - for (item, item_range) in item_text_ranges.iter().enumerate() { - let Some(annotation_width) = ruby_annotation_widths.get(&item).copied() - else { - continue; - }; - // Only a SPLIT base qualifies; whole bases took - // the shaping-time spread law already. - let split = item_range.start < line_range.end - && line_range.start < item_range.end - && (item_range.start < line_range.start - || item_range.end > line_range.end); - if !split { - continue; - } - let seg_start = item_range.start.max(line_range.start); - let seg_end = item_range.end.min(line_range.end); - if seg_end <= seg_start { - continue; - } - if split_spread_edits - .iter() - .chain(new_edits.iter()) - .any(|(range, _)| range.start == seg_start) - { - continue; - } - let annotation_text = match &tree.node(root).content { - FormattingNodeContent::InlineFlow { items } => { - match items.get(item) { - Some(InlineItem::Text { - ruby_annotation: Some(annotation), - .. - }) => annotation.text.clone(), - _ => continue, - } - } - _ => continue, - }; - let total_chars = text - .get(item_range.clone()) - .map_or(0, |base| base.chars().count()); - if total_chars == 0 { - continue; - } - let before = text - .get(item_range.start..seg_start) - .map_or(0.0, |t| t.chars().count() as f64); - let through = text - .get(item_range.start..seg_end) - .map_or(0.0, |t| t.chars().count() as f64); - let allocated = rito_fragment::allocate_ruby_annotation( - &annotation_text, - before / total_chars as f64, - if seg_end >= item_range.end { - f64::INFINITY - } else { - through / total_chars as f64 - }, - ); - if allocated.is_empty() { - continue; - } - let allocated_width = annotation_width - * allocated.chars().count() as f64 - / annotation_text.chars().count().max(1) as f64; - let mut segment_advance = 0.0_f64; - let mut cluster = - parley::layout::Cluster::from_byte_index(&layout, seg_start); - while let Some(current) = cluster { - if current.text_range().start >= seg_end { - break; - } - segment_advance += f64::from(current.advance()); - cluster = current.next_logical(); - } - let excess = allocated_width - segment_advance; - if excess <= 0.01 { - continue; - } - let seg_chars = text - .get(seg_start..seg_end) - .map_or(0, |t| t.chars().count()); - if seg_chars == 0 { - continue; - } - let n = seg_chars as f64; - let gap = excess / n; - let last_cluster_start = text - .get(seg_start..seg_end) - .and_then(|t| t.char_indices().next_back()) - .map_or(seg_start, |(offset, _)| seg_start + offset); - if seg_chars >= 2 && last_cluster_start > seg_start { - new_edits.push((seg_start..last_cluster_start, gap as f32)); - } - // Edge share carried by the segment's last - // cluster (half a share per side). - new_edits - .push((last_cluster_start..seg_end, (excess / (2.0 * n)) as f32)); - } - } - if !new_edits.is_empty() { - split_spread_edits.extend(new_edits); - split_spread_rounds += 1; - continue; - } - } - // The straddle pass above holds pairs a line break separated - // at full width. For the line-end extension those openers - // must be measured back in their mid-line (trimmed) form — - // collect (opener byte, pair left byte) for every suppressed - // opener-halt pair. - let suppressed_openers: Vec<(usize, usize)> = suppressed_pair_trims - .iter() - .filter_map(|&left_byte| { - let left = text[left_byte..].chars().next()?; - let right_byte = left_byte + left.len_utf8(); - let right = text[right_byte..].chars().next()?; - (cjk_punctuation_trim(left, right) == Some(TrimmedGlyph::Right)) - .then_some((right_byte, left_byte)) - }) - .collect(); - // Both the line-end trim and the rewind need an eligible - // closer in a `halt` face to overflow; a paragraph without - // one cannot take either at any width, so neither scan runs. - let scanned_lines = if *line_end_trims - .get_or_init(|| line_end_trims_possible(&self.catalog, &layout, &text)) - { - layout.len().saturating_sub(1) - } else { - 0 - }; - let mut line_top = 0.0_f64; - let candidate = (0..scanned_lines).find_map(|index| { - // The text-indent margin narrows the first line's - // available advance exactly as it narrowed Parley's fit. - let indent = if index == 0 { first_line_indent } else { 0.0 }; - // The trim candidate models the breaker's fit, so it sees - // the same epsilon-widened advance the breaker used. - let max_advance = line_max_advance(line_top) + LINE_FIT_EPSILON as f32 - indent; - line_top += layout - .get(index) - .map_or(0.0, |line| f64::from(line.metrics().line_height)); - // An engine-forced break (a rewind, a ruby split) is not a - // fit decision, but parley stamps it BreakReason::Regular - // all the same — extending past one would fabricate a - // candidate out of the very content the rewind pushed - // down and then unwind the rewind (measured: b1's - // razor-fit ① note line re-merged this way). - if forced_line_breaks.iter().any(|(line, _)| *line == index) { - return None; - } - line_end_trim_candidate( - &self.catalog, - &layout, - &text, - index, - max_advance, - |byte| end_trims.contains(&byte) || rejected_trims.contains(&byte), - &suppressed_openers, - &mut bounds, - ) - }); - match candidate { - Some((byte, unsuppress)) => { - end_trims.push(byte); - suppressed_pair_trims.retain(|left| !unsuppress.contains(left)); - pending_trim = Some(byte); - forced_line_breaks.clear(); - } - None => { - // Rejected-extension rewind: when the line-end trim - // extension would fit but the line crosses an element - // boundary (the single-item gate), the browser sends - // the WHOLE overflowing item to the next line instead - // of breaking greedily inside it. Force that line to - // hold exactly the clusters before the item and - // re-lay; one rewind per pass keeps earlier line - // indices stable. - let mut line_top = 0.0_f64; - let mut rewound = false; - for index in 0..scanned_lines { - let indent = if index == 0 { first_line_indent } else { 0.0 }; - let max_advance = - f64::from(line_max_advance(line_top) - indent) + LINE_FIT_EPSILON; - line_top += layout - .get(index) - .map_or(0.0, |line| f64::from(line.metrics().line_height)); - if forced_line_breaks.iter().any(|(line, _)| *line == index) { - continue; - } - let Some(count) = rewind_break_count( - &self.catalog, - &layout, - &text, - index, - max_advance, - &item_text_ranges, - &mut bounds, - ) else { - continue; - }; - forced_line_breaks.push((index, count)); - rewound = true; - break; - } - if rewound { - continue; - } - break ( - layout, - alignment, - shifted_ranges, - first_line_indent, - item_box_sheds, - forced_line_indents, - ruby_spreads, - ruby_spread_overhangs, - ruby_spread_overhangs_right, - ruby_center_shifts, - opener_halt_trims, - inline_block_boxes, - inline_block_baselines, - image_edge_insets, - empty_box_struts, - spacing_edits, - cluster_steps, - ); - } - } - } - }; + } = decided; let mut layout = layout; // Read after the line breaking above (a cluster is found through // its line) and kept with the shaped paragraph for later layouts. @@ -728,10 +165,36 @@ impl FormattingContext for ParleyInlineContext { | InlineItem::EmptyBox { baseline_shift_px, .. } => *baseline_shift_px, + InlineItem::OpenTag { .. } | InlineItem::CloseTag { .. } => 0.0, }) .collect(), _ => Vec::new(), }; + // Per item: how far above its parent's baseline a `middle`-aligned + // atom centres, read once per layout; the raise it takes follows + // from its own box (`atom_align::middle_raise`). + let middle_half_x_heights = self.middle_half_x_heights(tree, root); + let inline_block_item_of: std::collections::HashMap = + match &tree.node(root).content { + FormattingNodeContent::InlineFlow { items } => items + .iter() + .enumerate() + .filter_map(|(index, item)| match item { + InlineItem::InlineBlock { node, .. } => Some((node.0, index)), + _ => None, + }) + .collect(), + _ => std::collections::HashMap::new(), + }; + let atom_raise = |item_index: usize, above: f64, below: f64| -> f64 { + middle_half_x_heights + .get(item_index) + .copied() + .flatten() + .map_or(0.0, |half| { + crate::atom_align::middle_raise(above, below, half) + }) + }; // Per item: a declared line-height resolves to a fixed height; a // `normal` item defers to its fonts' normal-line metrics, chosen // per line. Indexed like `item_text_ranges`. @@ -750,7 +213,9 @@ impl FormattingContext for ParleyInlineContext { InlineItem::Text { text, .. } => text.as_str(), InlineItem::Image { .. } | InlineItem::InlineBlock { .. } - | InlineItem::EmptyBox { .. } => "", + | InlineItem::EmptyBox { .. } + | InlineItem::OpenTag { .. } + | InlineItem::CloseTag { .. } => "", }) .collect(), _ => String::new(), @@ -778,6 +243,8 @@ impl FormattingContext for ParleyInlineContext { style: *style, declared: None, }), + // A box's start and end sizes hold no content to size. + InlineItem::OpenTag { .. } | InlineItem::CloseTag { .. } => None, }) .collect(), _ => Vec::new(), @@ -806,14 +273,32 @@ impl FormattingContext for ParleyInlineContext { // block position comes from accumulation instead. let mut running_top = 0.0_f64; // The previous line's leading below its text, spent by a ruby - // annotation on the next line before the line has to grow. - let mut prev_ruby_below: Option = None; - // The distinct fonts that shaped the previous line's glyph runs. - // The browser's under-edge allowance depends on the previous - // line's font composition (measured: one Latin glyph — a space - // included — shrinks the gap a following annotation may reuse by - // one pixel at 16px), so the reuse probe must match it. - let mut prev_line_fonts: Vec<(u64, u32)> = Vec::new(); + // annotation on the next line before the line has to grow. The + // first line's previous line is the preceding block's last, when + // the block context lends its space (Blink's block-start + // annotation space). + let mut prev_ruby_below: Option = space.preceding_line.map(|line| line.below); + // The fonts that shaped the previous line's glyph runs. The + // browser's under-edge allowance depends on the previous line's + // font composition (measured: one Latin glyph — a space included + // — shrinks the gap a following annotation may reuse by one pixel + // at 16px), so the reuse probe must match it. + let mut prev_line_fonts = space + .preceding_line + .map_or(LineFonts::None, |line| line.fonts); + // An empty previous line lends only its half-leading: with no text + // to measure under, its content area is the font size centred in + // the box (measured: after `


` the annotation reuses + // (line height − font size) / 2, not a text line's under-edge). + let mut prev_half_leading = space.preceding_line.map_or(0.0, |line| line.half_leading); + // The previous line's block-end annotation space (Blink's): room + // under its text an emphasis mark over the next line reuses, or + // (negative) how far marks under it overflowed its box. + let mut prev_annotation_space = space + .preceding_line + .map_or(0.0, |line| line.annotation_space); + // Each text item's em box offsets, per style and faces. + let mut em_boxes = EmBoxes::default(); // Whether the last line laid out holds any item: a trailing empty // line is dropped below. let mut last_line_has_items = true; @@ -827,6 +312,9 @@ impl FormattingContext for ParleyInlineContext { .next() .map(|item| item.style) }); + let strut_font_size = strut_style_id + .and_then(|id| style_tables.and_then(|tables| tables.inline.style(id).ok())) + .map(|resolved| f64::from(resolved.font.size.get())); let strut_envelope: Option<(f64, f64)> = strut_style_id .and_then(|id| style_tables.and_then(|tables| tables.inline.style(id).ok())) .filter(|resolved| matches!(resolved.font.line_height, LineHeight::Normal)) @@ -845,9 +333,9 @@ impl FormattingContext for ParleyInlineContext { let no_clusters: std::rc::Rc<[rito_fragment::ClusterPosition]> = std::rc::Rc::from([]); for line in layout.lines() { let metrics = line.metrics(); - let line_top = running_top; + let mut line_top = running_top; let line_text = text_lines.at(line_top); - let has_inline_box = line.has_inline_box(); + let has_inline_box = line_has_atom(&line); // Env-gated line forensics for the native probe binary (wasm // has no env; the flag simply never sets there). // Flow-text ranges of the spread ruby bases: justification must @@ -897,13 +385,6 @@ impl FormattingContext for ParleyInlineContext { nbsp_kept, ) }; - // A span opening this forced-break line indents it by its box - // lead (margins/padding/border of a post-
span land on - // the span's own line). - let forced_indent = forced_line_indents - .get(&line.text_range().start) - .copied() - .unwrap_or(0.0); let parley_line_x = f64::from(metrics.offset); // The hang shift moves the PAINTED line: children below are // relativized against parley's own aligned offset so the @@ -931,6 +412,16 @@ impl FormattingContext for ParleyInlineContext { 0.0 } }; + // Rubies on a line the breaker rewound to an item boundary + // widen it for alignment by their overhangs (see + // `rewound_ruby_overhangs`). + let rewind_width = rewound_ruby_overhangs( + line.text_range(), + line.break_reason(), + &item_text_ranges, + &ruby_spread_overhangs, + &ruby_spread_overhangs_right, + ); // An alignment offset lands on the LayoutUnit grid by // FLOORING (Range-measured: a right-aligned 4-glyph 15.2px // line starts at 579.1875 = floor64(579.2), the .8 fraction @@ -938,11 +429,12 @@ impl FormattingContext for ParleyInlineContext { // Start-aligned lines carry no offset and keep raw floats. let line_x = { let raw = parley_line_x - alignment_epsilon - + forced_indent + match alignment { - parley::Alignment::Center => (hang_uncovered - trailing_nbsp_kept) / 2.0, + parley::Alignment::Center => { + (hang_uncovered - trailing_nbsp_kept - rewind_width) / 2.0 + } parley::Alignment::End | parley::Alignment::Right => { - hang_uncovered - trailing_nbsp_kept + hang_uncovered - trailing_nbsp_kept - rewind_width } _ => 0.0, }; @@ -968,8 +460,7 @@ impl FormattingContext for ParleyInlineContext { } else { 0.0 }; - let target = - f64::from(line_max_advance(line_top)) - f64::from(indent) - forced_indent; + let target = f64::from(line_max_advance(line_top)) - f64::from(indent); // The hanging U+3000 tail leaves the measure like parley's // own trailing whitespace does: Blink justifies the line's // content to the full measure with the spaces hung outside. @@ -1014,93 +505,28 @@ impl FormattingContext for ParleyInlineContext { // A line break is a shaping boundary: the browser // re-measures the broken line, so a kern pair straddling // the break never applies and the line-final cluster - // keeps its base advance (measured: SourceHan ン+ス - // carries a -29/1000 kern pair; the paragraph-shaped ン - // leaked that kern into the line's justified natural - // width, inflating the slack 0.416px and phasing every - // share-driven glyph mid-line). - { - use skrifa::raw::TableProvider as _; - use skrifa::MetadataProvider as _; - let content = flow_text - .get(range.clone()) - .map(str::trim_end) - .unwrap_or(""); - if let Some(last_char) = content.chars().next_back().filter(|last| { - // Only the MEASURED domain takes the delta: a - // kana or ideograph line end (SourceHan pair - // kerns live there). Fullwidth punctuation runs - // the trim/hang machinery — the delta - // double-counted a trailing 、's compression — - // and a latin or fullwidth-symbol line end - // (~, letters) moved a credits line off the - // truth when the delta was applied wholesale. - matches!(u32::from(*last), - 0x3041..=0x30FF - | 0x3400..=0x9FFF - | 0xF900..=0xFAFF - | 0x20000..=0x2FA1F) - }) { - let last_byte = range.start + content.len() - last_char.len_utf8(); - if let Some(cluster) = - parley::layout::Cluster::from_byte_index(&layout, last_byte) - { - let shaped = f64::from(cluster.advance()); - let run = cluster.run(); - let font = run.font(); - if let Ok(font_ref) = - skrifa::FontRef::from_index(font.data.as_ref(), font.index) - { - // Unscaled font units scaled in f64: - // skrifa's pre-scaled metrics quantize - // the scale factor and return 14.3907 - // for a 1000-unit glyph at 14.4px — a - // phantom -0.0093 delta on every - // ideograph line end. - let upem = font_ref - .head() - .map(|head| f64::from(head.units_per_em())) - .unwrap_or(1000.0); - let upem = if upem > 0.0 { upem } else { 1000.0 }; - let glyph_metrics = font_ref.glyph_metrics( - skrifa::instance::Size::unscaled(), - skrifa::instance::LocationRef::default(), - ); - let scale = f64::from(run.font_size()) / upem; - let base: f64 = cluster - .glyphs() - .map(|glyph| { - glyph_metrics - .advance_width(skrifa::GlyphId::new(glyph.id)) - .map(|units| f64::from(units) * scale) - .unwrap_or(f64::from(glyph.advance)) - }) - .sum(); - let delta = base - shaped; - // Only a REAL pair adjustment: kern - // pairs move whole font units (1/128px - // and up), while float dust between the - // shaper's f32 sum and the metrics read - // is ~1e-5 — letting dust through pushed - // an ideograph-final line's advance over - // the ceil64 margin and dropped its - // slack a whole 1/64. Features that - // legitimately resize a glyph (halved - // ruby punctuation via halt) move half - // an em or more and stay shaped. - if crate::forensics::break_debug() { - eprintln!( - "[brk] last='{last_char}' shaped={shaped:.6} base={base:.6} delta={delta:.6}" - ); - } - if delta.abs() > 1.0 / 128.0 - && delta.abs() < f64::from(run.font_size()) * 0.25 - { - if let Some((_, sum, _)) = item_advances.last_mut() { - *sum += delta; - } - } - } + // keeps the advance it shapes to alone (Blink's + // `reshape_line_end`, wherever the shaper marked the break + // unsafe; measured: SourceHan ン+ス carries a -29/1000 kern + // pair, Tinos A, ’ and the space carry their own, and the + // paragraph-shaped glyph leaked that kern into the line's + // justified natural width, phasing every share-driven glyph + // mid-line). + let content = flow_text.get(range.clone()).map_or("", |line| { + line.trim_end_matches(|character: char| { + character.is_whitespace() && character != '\u{A0}' + }) + }); + if let Some(last_char) = content.chars().next_back() { + let last_byte = range.start + content.len() - last_char.len_utf8(); + let delta = parley::layout::Cluster::from_byte_index(&layout, last_byte) + .map_or(0.0, |cluster| f64::from(cluster.line_end_delta())); + if crate::forensics::break_debug() { + eprintln!("[brk] last='{last_char}' line-end delta={delta:.6}"); + } + if delta != 0.0 { + if let Some((_, sum, _)) = item_advances.last_mut() { + *sum += delta; } } } @@ -1128,9 +554,10 @@ impl FormattingContext for ParleyInlineContext { line_justify_plan( &flow_text, range, - target - advance, + target - advance - rewind_width, &spread_ranges, &atom_positions, + &combined_ranges, ) } else { None @@ -1153,12 +580,38 @@ impl FormattingContext for ParleyInlineContext { justify_plan.is_some(), ); } + // A justified paragraph aligns a wide-annotation ruby's base to + // its line's start edge, and a justified line's to its end edge + // (Blink's `PlaceRubyColumn`): where the line's in-flow content + // starts and ends, unless an atom takes the edge. + let (ruby_line_start, ruby_line_end) = if justify && !ruby_base_shifts.is_empty() { + let in_flow_text = |item: &PositionedLayoutItem<'_, [u8; 4]>| match item { + PositionedLayoutItem::GlyphRun(_) => Some(true), + PositionedLayoutItem::InlineBox(inline_box) => { + (inline_box.kind == parley::InlineBoxKind::InFlow).then_some(false) + } + }; + let range = line.text_range(); + let justified_line = matches!( + line.break_reason(), + parley::layout::BreakReason::Regular | parley::layout::BreakReason::Emergency + ); + let starts_with_text = line.items().find_map(|item| in_flow_text(&item)); + let ends_with_text = line.items().filter_map(|item| in_flow_text(&item)).last(); + ( + (starts_with_text == Some(true)).then_some(range.start), + (justified_line && ends_with_text == Some(true)).then_some(range.end), + ) + } else { + (None, None) + }; // Expansion shares consumed at boundaries before the walk's // current position; each share moves everything after it. let mut justify_shares_used = 0u32; - // (item index, truth item start, engine item start) for the - // LayoutUnit item cursor on justified lines. - let mut justify_item_track: Option<(usize, f64, f64)> = None; + // (item index, truth item start, engine item start, item start + // byte, shares used at the item's start) for the LayoutUnit + // item cursor on justified lines. + let mut justify_item_track: Option<(usize, f64, f64, usize, u32)> = None; // The same LayoutUnit item cursor for UNJUSTIFIED lines at // OFF-GRID font sizes: a style-item boundary re-anchors the // next item's start at the ceiling of the running float end @@ -1342,7 +795,9 @@ impl FormattingContext for ParleyInlineContext { InlineItem::Text { style, .. } | InlineItem::Image { style, .. } | InlineItem::InlineBlock { style, .. } - | InlineItem::EmptyBox { style, .. } => *style, + | InlineItem::EmptyBox { style, .. } + | InlineItem::OpenTag { style } + | InlineItem::CloseTag { style } => *style, }) } _ => None, @@ -1362,8 +817,12 @@ impl FormattingContext for ParleyInlineContext { .iter() .find(|(halt, _)| halt.start < range.end && range.start < halt.end) .map_or(0.0, |(_, half)| *half); - let ruby_center_shift_px = - ruby_center_shifts.get(&item_index).copied().unwrap_or(0.0); + let ruby_center_shift_px = ruby_base_shifts + .get(&item_index) + .zip(item_text_ranges.get(item_index)) + .map_or(0.0, |(shift, base)| { + shift.on_line(base, ruby_line_start, ruby_line_end) + }); // A ruby base or a trimmed opener paints from // fields of its own, and a vertical column steps // each piece by its own spacing: those stay apart. @@ -1396,6 +855,7 @@ impl FormattingContext for ParleyInlineContext { clusters: std::rc::Rc::clone(&no_clusters), cluster_grid: false, segments: None, + orientations: None, }, shift, item: item_index, @@ -1414,13 +874,6 @@ impl FormattingContext for ParleyInlineContext { .grid }); }; - // The advance a box gap parked on this run's last - // cluster: shed it so the rect stays ink-sized. - let box_shed = if run_range.end == item_range.end { - item_box_sheds.get(&item_index).copied().unwrap_or(0.0) - } else { - 0.0 - }; match &justify_plan { None => { // The canvas shapes each fillText call on @@ -1540,7 +993,9 @@ impl FormattingContext for ParleyInlineContext { InlineItem::Text { style, .. } | InlineItem::Image { style, .. } | InlineItem::InlineBlock { style, .. } - | InlineItem::EmptyBox { style, .. } => *style, + | InlineItem::EmptyBox { style, .. } + | InlineItem::OpenTag { style } + | InlineItem::CloseTag { style } => *style, }) } _ => None, @@ -1651,13 +1106,7 @@ impl FormattingContext for ParleyInlineContext { emit( seg_start..byte, run_x + cjk_anchor_correction + seg_offset, - cumulative - - seg_offset - - if byte == run_range.end { - box_shed - } else { - 0.0 - }, + cumulative - seg_offset, 0.0, ); seg_start = byte; @@ -1665,12 +1114,7 @@ impl FormattingContext for ParleyInlineContext { } } } else if !has_space { - emit( - run_range, - run_x, - f64::from(glyph_run.advance()) - box_shed, - 0.0, - ); + emit(run_range, run_x, f64::from(glyph_run.advance()), 0.0); } else { let mut seg_start = run_range.start; let mut seg_x = run_x; @@ -1700,9 +1144,7 @@ impl FormattingContext for ParleyInlineContext { emit( seg_start..run_range.end, seg_x, - run_x + f64::from(glyph_run.advance()) - - seg_x - - box_shed, + run_x + f64::from(glyph_run.advance()) - seg_x, 0.0, ); } @@ -1777,19 +1219,84 @@ impl FormattingContext for ParleyInlineContext { // dust must not bump a whole 1/64. let ceil64 = |value: f64| ((value - 1.0 / 1024.0) * 64.0).ceil() / 64.0; + // An item of glyphs alone carries its + // advance exactly: the shaper's fixed-point + // advances plus its shares, each share as + // the browser holds it (the slack per + // opportunity truncated to 1/65536). No + // dust margin then: a share 0.000625px over + // the grid still lifts the item end a whole + // 1/64 (measured: a run of 16px ruby bases + // at a 0.36 share steps 16.375 each). + let fixed_item_advance = |from: usize, to: usize, shares: u32| { + let mut natural = 0.0_f64; + let mut raw = 0.0_f64; + let mut cluster = + parley::layout::Cluster::from_byte_index(&layout, from); + while let Some(current) = cluster { + let range = current.text_range(); + if range.start >= to { + break; + } + // Spaces ride word spacing apart + // from their glyphs (Blink's + // `TreatAsSpace`). + if flow_text.get(range.clone()).is_none_or(|t| { + t.chars() + .any(|c| matches!(c, ' ' | '\t' | '\n' | '\u{a0}')) + }) { + return None; + } + natural += hb_fixed_cluster_advance( + &self.catalog, + ¤t, + folded_spacing(spacing_edits, ¤t), + ); + raw += f64::from(current.advance()); + cluster = current.next_logical(); + } + let share = (plan.share * 65536.0).floor() / 65536.0; + Some((natural + share * f64::from(shares), raw)) + }; let run_x = run_x + match &mut justify_item_track { slot @ None => { - *slot = Some((item_index, justified_x, justified_x)); + *slot = Some(( + item_index, + justified_x, + justified_x, + run_range.start, + justify_shares_used, + )); 0.0 } - Some((item, truth_start, engine_start)) => { + Some((item, truth_start, engine_start, start, shares)) => { if *item != item_index { let advance = justified_x - *engine_start; - let truth = ceil64(*truth_start + advance); + let used = justify_shares_used - *shares; + let between = + advance - plan.share * f64::from(used); + let truth = match fixed_item_advance( + *start, + run_range.start, + used, + ) { + // Nothing but the item's + // glyphs lies between. + Some((exact, raw)) + if (raw - between).abs() < 0.01 => + { + ((*truth_start + exact) * 64.0 - 1e-6) + .ceil() + / 64.0 + } + _ => ceil64(*truth_start + advance), + }; *item = item_index; *truth_start = truth; *engine_start = justified_x; + *start = run_range.start; + *shares = justify_shares_used; truth - justified_x } else { *truth_start - *engine_start @@ -1885,8 +1392,7 @@ impl FormattingContext for ParleyInlineContext { emit( stretch_start..run_range.end, stretch_x, - stretch_natural + plan.share * f64::from(stretch_shares) - - box_shed, + stretch_natural + plan.share * f64::from(stretch_shares), plan.share * f64::from(uniform.unwrap_or(0)), ); } @@ -1906,7 +1412,15 @@ impl FormattingContext for ParleyInlineContext { .get(inline_box.id as usize) .copied() .unwrap_or(0.0); - max_rise = max_rise.max(shift); + let height = f64::from(inline_box.height); + let (atom_above, atom_below) = match inline_block_boxes.get(&inline_box.id) + { + Some((baseline, _)) => (*baseline, height - baseline), + None => atom_baseline_extent(height, vertical_flow), + }; + max_rise = max_rise.max( + shift + atom_raise(inline_box.id as usize, atom_above, atom_below), + ); if let Some((baseline, mini)) = inline_block_boxes.remove(&inline_box.id) { // Parley rests the box bottom on the text // baseline; an inline-block instead hangs its @@ -1937,6 +1451,7 @@ impl FormattingContext for ParleyInlineContext { height, }, children: mini.children, + cuts: rito_fragment::BlockCuts::NONE, }), shift, )); @@ -2345,7 +1860,7 @@ impl FormattingContext for ParleyInlineContext { matches!( item, InlineItem::Image { - align_top: true, + align: AtomAlign::Top, .. } ) @@ -2356,8 +1871,14 @@ impl FormattingContext for ParleyInlineContext { // when taller than it (handled after the max). top_aligned_heights.push(image.rect.height); } else { - above = above.max(image.rect.height + shift); - below = below.max(-shift); + let (image_above, image_below) = + atom_baseline_extent(image.rect.height, vertical_flow); + // A `middle` raise moves the box alone; the + // strut below stays with the enclosing box. + let raised = shift + + atom_raise(image.item_index as usize, image_above, image_below); + above = above.max(image_above + raised); + below = below.max(image_below - raised); } let item = tree_items @@ -2413,8 +1934,14 @@ impl FormattingContext for ParleyInlineContext { .get(&atom.source.0) .copied() .unwrap_or(atom.rect.height); - above = above.max(baseline + shift); - below = below.max((atom.rect.height - baseline) - shift); + let raised = shift + + inline_block_item_of + .get(&atom.source.0) + .map_or(0.0, |index| { + atom_raise(*index, baseline, atom.rect.height - baseline) + }); + above = above.max(baseline + raised); + below = below.max((atom.rect.height - baseline) - raised); let item_style = tree_items.iter().find_map(|item| match item { InlineItem::InlineBlock { node, style, .. } if node.0 == atom.source.0 => { Some(*style) @@ -2427,6 +1954,19 @@ impl FormattingContext for ParleyInlineContext { let Some(resolved) = resolved else { continue; }; + // Its strut stands on the line's own baseline, as every + // box on a line does, whatever the block's own writing + // mode (an orthogonal block across a vertical line). + let mut on_line; + let resolved = if vertical_flow + && resolved.bidi.writing_mode == WritingMode::HorizontalTopToBottom + { + on_line = resolved.clone(); + on_line.bidi.writing_mode = WritingMode::VerticalRightToLeft; + &on_line + } else { + resolved + }; let Some(metric) = self.normal_line(resolved, &LineProbe::Strut) else { complete = false; continue; @@ -2550,19 +2090,19 @@ impl FormattingContext for ParleyInlineContext { // under its text. Whatever the baseline still lacks becomes // growth. The base font (the first glyph run over the ruby // base) tells whether the previous line mixed fonts. - let base_font_key = |range: &std::ops::Range| -> Option<(u64, u32)> { - line.style_runs().find_map(|item| { - let parley::LineStyleRun::Glyphs { run, .. } = item else { - return None; - }; - let shaped = run.text_range(); - if shaped.start >= range.end || range.start >= shaped.end { - return None; - } - let font = run.font(); - Some((font.data.id(), font.index)) - }) - }; + let base_font_key = + |range: &std::ops::Range| -> Option { + line.style_runs().find_map(|item| { + let parley::LineStyleRun::Glyphs { run, .. } = item else { + return None; + }; + let shaped = run.text_range(); + if shaped.start >= range.end || range.start >= shaped.end { + return None; + } + Some(self.catalog.face_id(run.font())) + }) + }; // A vertical-rl flow's annotation shares NO half-leading with // its base the way a horizontal line's does: the annotation // column needs its own width beyond the base's half-leading @@ -2570,39 +2110,10 @@ impl FormattingContext for ParleyInlineContext { // ratios x 3 annotation lengths: growth = rt size minus the // half-leading, floored at zero, independent of annotation // length, the whole growth landing on the line's right). - let ruby_growth = if vertical_flow { - let mut growth = 0.0_f64; - for (index, range) in item_text_ranges.iter().enumerate() { - let on_line = line_text_range - .is_some_and(|(start, end)| range.start < end && start < range.end); - if !on_line || range.is_empty() { - continue; - } - let Some(InlineItem::Text { - ruby_annotation: Some(annotation), - style, - .. - }) = tree_items.get(index) - else { - continue; - }; - let Some(resolved) = - style_tables.and_then(|tables| tables.inline.style(*style).ok()) - else { - continue; - }; - let fs = f64::from(resolved.font.size.get()); - let annotation_size = fs * f64::from(annotation.size_ratio); - // The annotation column asks for its font size plus a - // half pixel under the pinned CJK serif (matrix: - // rt 6/8/10/14 across four line-heights and three - // base sizes all measure need = rt + 0.5). - growth = - growth.max((annotation_size + 0.5 - (line_height - fs) / 2.0).max(0.0)); - } - growth - } else { + let (ruby_growth, ruby_overflow, ruby_lent) = { let mut growth = 0.0_f64; + // The deficit with nothing above to reach into. + let mut overflow = 0.0_f64; for (index, range) in item_text_ranges.iter().enumerate() { let on_line = line_text_range .is_some_and(|(start, end)| range.start < end && start < range.end); @@ -2685,9 +2196,8 @@ impl FormattingContext for ParleyInlineContext { let base_latin = flow_text .get(range.clone()) .is_some_and(|base| !base.chars().any(is_cjk)); - let prev_mixed = !prev_line_fonts.is_empty() - && base_font - .is_some_and(|base| prev_line_fonts.iter().any(|key| *key != base)); + let prev_mixed = + base_font.is_some_and(|face| prev_line_fonts.has_other_than(face)); // The shape's rt carries the annotation's ACTUAL text: // the annotation stack height depends on which face // the family list resolves for those characters, and @@ -2724,8 +2234,13 @@ impl FormattingContext for ParleyInlineContext { // below-edge allowance = below extent minus the // second-line reduction. let reuse = (ruby_two.height - ruby_one.height - plain.ascent()).max(0.0); - let prev_gap = prev_ruby_below.map_or(0.0, |below| (below - reuse).max(0.0)); + let prev_gap = match prev_ruby_below { + Some(_) if prev_line_fonts == LineFonts::None => prev_half_leading, + Some(below) => (below - reuse).max(0.0), + None => 0.0, + }; growth = growth.max((required - baseline - prev_gap).max(0.0)); + overflow = overflow.max((required - baseline).max(0.0)); } // The browser pushes a growing FIRST line down // by a WHOLE pixel count — ceil of the baseline deficit — @@ -2738,43 +2253,106 @@ impl FormattingContext for ParleyInlineContext { // interior case in the matrix). b20's un-ceiled openers // sat 0.55px high and binned to −1 rows on half the // dialog lines. - if prev_ruby_below.is_none() { - // The paragraph's own padding-top absorbs a FIRST - // line's annotation growth: the annotation overflows - // upward into the padding and the line keeps its - // natural height (padding oracle: an 8px pad absorbs - // the whole 6px growth, a 4px pad absorbs 4; b52's - // contents rows sat 8px low when the growth ignored - // their 0.9em padding). Percentages have no basis - // here and absorb nothing. - let padding_absorb = tree - .styles() - .and_then(|tables| tables.layout.style(tree.node(root).style).ok()) - .map_or(0.0, |style| match style.padding.top.value() { - rito_style_contract::LengthPercentage::Length(px) => { - f64::from(px.get()) - } - _ => 0.0, - }); - (growth - padding_absorb).max(0.0).ceil() + // A first line with no previous line takes the whole + // overflow; the block context spends the space above it + // (padding, collapsed margins, the preceding block's + // leading), as Blink's block-start annotation space does. + // A vertical line's em boxes sit around its central + // baseline, on the half pixel an odd font height leaves, + // and its push keeps that half (a 17px-high primary font's + // 2.5px push, measured). + let overflow = if vertical_flow { + overflow } else { - growth + overflow.ceil() + }; + if lines.is_empty() { + // The first line is laid out at its whole overflow + // whatever precedes the flow, so every fragment of the + // flow shares one geometry; what the preceding line + // lends rides `annotation_lent` to the line's opening. + let lent = if prev_ruby_below.is_some() { + (overflow - growth).max(0.0) + } else { + 0.0 + }; + (overflow, overflow, lent) + } else { + (growth, overflow, 0.0) } }; - let line_height = line_height + ruby_growth; - let baseline = baseline + ruby_growth; - running_top += ruby_growth; - prev_ruby_below = Some((line_height - baseline).max(0.0)); - prev_line_fonts.clear(); + // Emphasis marks reach past the line box the way Blink's + // annotation extent measures it. Over the line, they push it + // down by what the space under the previous line's text does + // not absorb, folded into the same growth as the ruby's; the + // first line takes its whole overflow and lends the rest to + // its opening. Under it, they grow the box at its bottom, and + // a next line with room above its text rises into that + // growth by as much as its room (the previous box gives it + // back). + let extent = if let Some(tables) = style_tables { + line_annotation_extent( + &self.line_annotation_boxes( + &line, + tree_items, + &item_text_ranges, + &item_shifts, + tables, + baseline, + &mut em_boxes, + ), + baseline, + line_height, + strut_font_size.unwrap_or(0.0), + ) + } else { + LineAnnotationExtent::default() + }; + let mut emphasis_shift = extent.overflow_over; + if prev_annotation_space < 0.0 && extent.space_over > 0.0 { + emphasis_shift = -extent.space_over.min(-prev_annotation_space); + } + if extent.overflow_over > 0.0 && prev_annotation_space > 0.0 { + emphasis_shift = (extent.overflow_over - prev_annotation_space).max(0.0); + } + let (annotation_growth, annotation_overflow, annotation_lent) = if lines.is_empty() { + let whole = ruby_growth.max(extent.overflow_over); + let net = (ruby_growth - ruby_lent).max(emphasis_shift.max(0.0)); + (whole, ruby_overflow.max(extent.overflow_over), whole - net) + } else { + ( + ruby_growth.max(emphasis_shift), + ruby_overflow.max(extent.overflow_over), + 0.0, + ) + }; + if emphasis_shift < 0.0 && ruby_growth == 0.0 { + if let Some(Fragment::Line(previous)) = lines.last_mut() { + previous.rect.height += emphasis_shift; + line_top += emphasis_shift; + running_top += emphasis_shift; + } + } + let line_height = line_height + annotation_growth + extent.overflow_under; + let baseline = baseline + annotation_growth; + running_top += annotation_growth + extent.overflow_under; + let annotation_space_end = if extent.overflow_under > 0.0 { + -extent.overflow_under + } else { + extent.space_under + }; + prev_annotation_space = annotation_space_end; + // A ruby on the next line reuses nothing under marks that + // overflow this one. + prev_ruby_below = + (extent.overflow_under == 0.0).then(|| (line_height - baseline).max(0.0)); + let mut line_fonts = LineFonts::None; for item in line.style_runs() { if let parley::LineStyleRun::Glyphs { run, .. } = item { - let font = run.font(); - let key = (font.data.id(), font.index); - if !prev_line_fonts.contains(&key) { - prev_line_fonts.push(key); - } + line_fonts = line_fonts.with(self.catalog.face_id(run.font())); } } + prev_line_fonts = line_fonts; if line_debug && (has_inline_box || item_shifts.iter().any(|shift| *shift != 0.0)) { eprintln!( "[line-debug] contributions={contributions:?} font_line={font_line:?} \ @@ -2794,14 +2372,31 @@ impl FormattingContext for ParleyInlineContext { if !ruby_spreads.is_empty() { merge_ruby_spread_fragments(&mut children, &item_text_ranges, &ruby_spreads); } + // The style a vertical line's text fragment was laid out in, + // whose clusters then say how they stand. + let vertical_style = |byte: u32| { + let item = item_text_ranges + .iter() + .position(|range| range.contains(&(byte as usize)))?; + let entry = item_line_heights.get(item)?.as_ref()?; + let style = style_tables?.inline.style(entry.style).ok()?; + (style.bidi.writing_mode != WritingMode::HorizontalTopToBottom).then_some(style) + }; let children: Vec = children .into_iter() .map(|(mut fragment, shift)| { - let adjust = max_rise - shift + ruby_growth; + let adjust = max_rise - shift + annotation_growth; match &mut fragment { Fragment::Text(text) => { text.rect.y = adjust; text.rect.height = base_height; + if let Some(style) = vertical_style(text.text_start) { + text.orientations = Some(self.cluster_orientations( + style, + &text.clusters, + &flow_text, + )); + } } // An atomic inline sits on the line's baseline: // its bottom margin edge rests there, however tall @@ -2817,7 +2412,7 @@ impl FormattingContext for ParleyInlineContext { matches!( item, InlineItem::Image { - align_top: true, + align: AtomAlign::Top, .. } ) @@ -2837,7 +2432,16 @@ impl FormattingContext for ParleyInlineContext { // independent). -shift } else { - baseline - shift - image.rect.height + let (image_above, image_below) = + atom_baseline_extent(image.rect.height, vertical_flow); + baseline + - shift + - atom_raise( + image.item_index as usize, + image_above, + image_below, + ) + - image_above }; } // An inline-block atom hangs its own baseline — @@ -2848,7 +2452,17 @@ impl FormattingContext for ParleyInlineContext { .get(&atom.source.0) .copied() .unwrap_or(atom.rect.height); - atom.rect.y = baseline - shift - mini_baseline; + let raise = + inline_block_item_of + .get(&atom.source.0) + .map_or(0.0, |index| { + atom_raise( + *index, + mini_baseline, + atom.rect.height - mini_baseline, + ) + }); + atom.rect.y = baseline - shift - raise - mini_baseline; } _ => {} } @@ -2875,11 +2489,19 @@ impl FormattingContext for ParleyInlineContext { }, baseline, trailing_whitespace: f64::from(metrics.trailing_whitespace), - ruby_growth, + annotation_growth, + annotation_overflow, + annotation_lent, + annotation_space_end, + fonts: line_fonts, marker, text_runs: line_runs, children, })); + if let Some(Fragment::Line(placed)) = lines.last() { + prev_half_leading = + strut_font_size.map_or(0.0, |size| placed.glyphless_end_space(size)); + } } // A forced break at the very end of the flow leaves one empty // trailing line; a browser generates no line box for a block-final @@ -2901,6 +2523,7 @@ impl FormattingContext for ParleyInlineContext { height: running_top, }, children: lines, + cuts: rito_fragment::BlockCuts::NONE, }), }, continuation: None, @@ -2933,6 +2556,10 @@ impl FormattingContext for ParleyInlineContext { .insert(tree.fingerprint(), node.0, sizes); Ok(sizes) } + + fn adopt_block_layout(&mut self, delegate: rito_fragment::BlockLayoutDelegate) { + self.block_layout = Some(delegate); + } } impl ParleyInlineContext { @@ -2960,9 +2587,7 @@ impl ParleyInlineContext { node, &BuildSpace::new(None, None, None), PercentageImageSizing::Shrunk, - &[], - &[], - &[], + ParagraphEdits::default(), &CancelFlag::new(), )?; let intrinsic = self.build_layout( @@ -2970,9 +2595,7 @@ impl ParleyInlineContext { node, &BuildSpace::new(None, None, None), PercentageImageSizing::Intrinsic, - &[], - &[], - &[], + ParagraphEdits::default(), &CancelFlag::new(), )?; // text-indent joins the first line's intrinsic contribution, as in @@ -2998,7 +2621,9 @@ impl ParleyInlineContext { InlineItem::Text { style, .. } | InlineItem::Image { style, .. } | InlineItem::InlineBlock { style, .. } - | InlineItem::EmptyBox { style, .. } => *style, + | InlineItem::EmptyBox { style, .. } + | InlineItem::OpenTag { style } + | InlineItem::CloseTag { style } => *style, }) } _ => None, @@ -3026,3 +2651,17 @@ impl ParleyInlineContext { }) } } + +/// How far a replaced atom of block size `height` stands above and hangs +/// below the baseline it sits on: it has no baseline of its own, so one is +/// synthesized from its box — its under edge for the alphabetic baseline +/// of a horizontal line, its middle for the central baseline of a vertical +/// one (the larger half above, on the 1/64 grid). +pub(crate) fn atom_baseline_extent(height: f64, vertical: bool) -> (f64, f64) { + if vertical { + let below = (height * 32.0).floor() / 64.0; + (height - below, below) + } else { + (height, 0.0) + } +} diff --git a/crates/rito-inline/src/lib.rs b/crates/rito-inline/src/lib.rs index 5146c1872..4077f4c11 100644 --- a/crates/rito-inline/src/lib.rs +++ b/crates/rito-inline/src/lib.rs @@ -17,7 +17,7 @@ //! blocks), `shaped` (the builds kept across layouts), `layout` (the //! provider entry points and the line loop), and the free laws the loop //! applies — `breaking`, `justify`, -//! `punctuation`, `marker`, `shaping`, `ruby`, `image`. Every module sees +//! `punctuation`, `marker`, `shaping`, `ruby`, `image`, `vertical`. Every module sees //! the whole crate through the root's re-exports, as the single file they //! were carved from did. @@ -29,25 +29,33 @@ use parley::{ StyleProperty, }; use rito_fragment::{ - BoxFragment, CancelFlag, ConstraintSpace, FormattingContext, FormattingNodeContent, + AtomAlign, BoxFragment, CancelFlag, ConstraintSpace, FormattingContext, FormattingNodeContent, FormattingNodeId, FormattingTree, Fragment, FragmentRect, FragmentTree, InlineItem, - IntrinsicInlineSizes, LayoutError, LayoutOutcome, LineFragment, LineText, TextFragment, + IntrinsicInlineSizes, LayoutError, LayoutOutcome, LineFonts, LineFragment, LineText, + TextFragment, }; use rito_style_contract::{ FontSlant, InlineFormattingStyle, LayoutFormattingStyle, LengthPercentage, LineHeight, - MaximumSize, PreferredSize, TextAlign, + MaximumSize, PreferredSize, TextAlign, TextOrientation, WritingMode, }; +mod atom_align; mod atoms; +mod break_opportunities; mod breaking; mod clusters; mod context; +#[cfg(test)] +mod counters; +mod emphasis; mod fonts; mod forensics; mod image; +mod inline_block; mod justify; mod layout; mod line_breaks; +mod line_decisions; mod line_items; mod line_metrics; mod line_text; @@ -58,20 +66,28 @@ mod ruby; mod segmented_lru; mod shaped; mod shaping; +mod spacing_plan; mod strut; #[cfg(test)] mod tests; +mod vertical; mod width_bounds; +pub(crate) use break_opportunities::{BreakContext, BreakOpportunities, RuleSet}; pub(crate) use breaking::*; pub(crate) use clusters::*; pub use clusters::{MeasuredRuby, MeasuredRun}; +pub(crate) use emphasis::*; +pub use emphasis::{emphasis_mark_size, takes_emphasis_mark, MeasuredEmphasis}; pub(crate) use fonts::*; pub use fonts::{font_family_name, HostFontFace, HostFontRole, PublicationFontFace}; pub(crate) use image::*; pub(crate) use justify::*; pub(crate) use line_breaks::{BreakKey, KeptBreaks}; -pub(crate) use line_items::{line_item_keys, line_items, LineItemView}; +pub(crate) use line_decisions::{ + line_max_advance, DecidedParagraph, KeptDecisions, LineEdgeInputs, LINE_FIT_EPSILON, +}; +pub(crate) use line_items::{line_has_atom, line_items, LineItemView}; pub(crate) use line_metrics::*; pub(crate) use line_text::{paints_alone, LineRuns, Piece}; pub(crate) use marker::*; @@ -83,8 +99,13 @@ pub(crate) use shaped::{ BuildSpace, IntrinsicSizes, KeptParagraph, ParagraphEdits, ShapeKey, ShapedParagraphs, }; pub(crate) use shaping::*; +pub(crate) use spacing_plan::{PlannedRun, RubyCarrier, SpacingPlan, SpacingPush}; pub use strut::layout_unit_ceil; pub(crate) use strut::*; +pub(crate) use vertical::{ + combined_advance, combines_upright, shapes_top_to_bottom, upright_ranges, CombinedKey, + CombinedSetting, UprightGlyph, UprightKey, +}; pub(crate) use width_bounds::WidthBounds; /// Inline formatting context backed by Parley shaping and line breaking. @@ -109,11 +130,21 @@ pub struct ParleyInlineContext { pub(crate) run_samples: RefCell>, /// Per-face `halt` feature presence, keyed by (blob id, face index) — /// the Han-kerning trim gate consults it for every trimmed character. - pub(crate) halt_feature_cache: RefCell>, + pub(crate) halt_feature_cache: RefCell>, + /// Where an upright glyph lies from its font's alphabetic baseline, + /// per (face, character, size bits): read from font tables once. + pub(crate) upright_glyphs: RefCell>>, + /// How each combined upright unit sets its string, per (text, font). + pub(crate) combined_settings: RefCell>, /// Paragraphs as built and not yet broken into lines, reused by every /// layout that reads them the same (see [`ShapedParagraphs`]). pub(crate) shaped: RefCell, /// Each paragraph's min- and max-content widths, measured once (see /// [`IntrinsicSizes`]). pub(crate) intrinsic_sizes: RefCell, + /// The block layout of an inline-block holding block-level boxes, + /// handed over by the block context that owns this one. + pub(crate) block_layout: Option, + #[cfg(test)] + pub(crate) counts: counters::LineLoopCounts, } diff --git a/crates/rito-inline/src/line_breaks.rs b/crates/rito-inline/src/line_breaks.rs index 02ed0bc5f..1699c0f69 100644 --- a/crates/rito-inline/src/line_breaks.rs +++ b/crates/rito-inline/src/line_breaks.rs @@ -14,19 +14,20 @@ const KEPT_BREAKS: usize = 4; /// Everything a build's line breaking reads besides the build: the space it /// lays out in (which also sized its images and inline blocks), the float -/// band its lines wrap around and the lines the loop forced. +/// band its lines wrap around and the lines the loop forced to end before +/// a text index. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct BreakKey { space: [Option; 3], band: Option<[u64; 3]>, - forced: Vec<(usize, u32)>, + forced: Vec<(usize, usize)>, } impl BreakKey { pub(crate) fn new( space: &ConstraintSpace, band: Option, - forced: &[(usize, u32)], + forced: &[(usize, usize)], ) -> Self { Self { space: [ diff --git a/crates/rito-inline/src/line_decisions/forward.rs b/crates/rito-inline/src/line_decisions/forward.rs new file mode 100644 index 000000000..f7b882b43 --- /dev/null +++ b/crates/rito-inline/src/line_decisions/forward.rs @@ -0,0 +1,934 @@ +//! The line edges decided one line at a time, the way Blink decides them +//! while it breaks each line (`ShapingLineBreaker::ShapeLine`): a line is +//! broken, its edge decides (a pair trim it separates, a pending line-end +//! trim confirmed or rejected, a ruby split rewound or spread, a closer the +//! line extends by, an item rewound), the line is broken again with the +//! decision until nothing more is decided, and the next line follows. No +//! decision reaches back to an earlier line. +//! +//! The paragraph is built once; its lines are broken on a copy whose +//! cluster advances take each decision as the build would (the spacing a +//! byte carries under the decided edits, from the build's spacing plan). +//! It is then built once more with the edits decided, and its lines must +//! end where the copy's did. + +use std::collections::{BTreeMap, HashMap}; +use std::ops::Range; + +use super::*; + +/// The edits and forced line ends a paragraph's lines decided: the line-end +/// trims and suppressed pair trims sorted, the split spreads as decided. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct LineEdits { + end_trims: Vec, + suppressed_pair_trims: Vec, + split_spreads: Vec<(Range, f32)>, + unhung_starts: Vec, + forced: BTreeMap, +} + +impl LineEdits { + pub(crate) fn edits(&self) -> ParagraphEdits<'_> { + ParagraphEdits { + end_trims: &self.end_trims, + suppressed_pair_trims: &self.suppressed_pair_trims, + split_spreads: &self.split_spreads, + unhung_starts: &self.unhung_starts, + } + } + + pub(crate) fn forced(&self) -> Vec<(usize, usize)> { + self.forced + .iter() + .map(|(line, end)| (*line, *end)) + .collect() + } + + /// Whether a build under `other` is a build under these edits. + fn builds_as(&self, other: &Self) -> bool { + self.end_trims == other.end_trims + && self.suppressed_pair_trims == other.suppressed_pair_trims + && self.split_spreads == other.split_spreads + && self.unhung_starts == other.unhung_starts + } +} + +/// How many spaces a build keeps its decided line edges for: a resize +/// passes through many, a reader returns to a few. +const KEPT_DECISIONS: usize = 4; + +/// The line edges a build decided in each space it was laid out in, and +/// the inline sizes the deciding pass's comparisons hold for, shared by +/// every copy of the build. A paragraph laid out again in a space it was +/// decided in replays them instead of deciding again. +#[derive(Default)] +pub(crate) struct KeptDecisions { + kept: std::cell::RefCell>, +} + +impl KeptDecisions { + fn get(&self, key: &BreakKey) -> Option<(LineEdits, WidthBounds)> { + let mut kept = self.kept.borrow_mut(); + let index = kept.iter().position(|(kept_key, ..)| kept_key == key)?; + let entry = kept.remove(index); + let found = (entry.1.clone(), entry.2); + kept.push(entry); + Some(found) + } + + fn keep(&self, key: BreakKey, edits: LineEdits, bounds: WidthBounds) { + let mut kept = self.kept.borrow_mut(); + kept.retain(|(kept_key, ..)| *kept_key != key); + if kept.len() == KEPT_DECISIONS { + kept.remove(0); + } + kept.push((key, edits, bounds)); + } +} + +fn insert_sorted(values: &mut Vec, value: usize) { + if let Err(at) = values.binary_search(&value) { + values.insert(at, value); + } +} + +fn remove_sorted(values: &mut Vec, value: usize) { + if let Ok(at) = values.binary_search(&value) { + values.remove(at); + } +} + +/// A line's end as the copy broke it, which the build must reproduce. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LineEnd { + range: Range, + reason: parley::layout::BreakReason, +} + +/// What a line's edge decided. +enum Decision { + /// A pair trim the break separates (its left byte). + Suppress(usize), + /// The pending line-end trim (its byte) the line did not end after. + Reject(usize), + /// The line rewound to end before a text index (a split ruby base's + /// segment that cannot fit its annotation). + Force(usize), + /// Split ruby segments spread on the line. + Spread(Vec<(Range, f32)>), + /// The ruby (its item index) that opens the line drops its start + /// overhang. + Unhang(usize), + /// A closer the line extends by, and the suppressed pairs it puts back + /// on one line. + Trim { byte: usize, unsuppress: Vec }, +} + +/// The paragraph a decision pass runs over and what it reads. +struct Pass<'a> { + context: &'a ParleyInlineContext, + inputs: &'a LineEdgeInputs<'a>, + source: &'a ParagraphLayout, + /// The edits `source` was built with. + source_edits: &'a LineEdits, + trims_possible: bool, +} + +impl ParleyInlineContext { + /// The paragraph at `inputs.root` built with its line edges decided and + /// broken into its lines; every comparison a decision made is recorded + /// in `bounds`. + pub(crate) fn decide_line_edges( + &self, + inputs: &LineEdgeInputs<'_>, + bounds: &mut WidthBounds, + ) -> Result { + let LineEdgeInputs { + tree, + root, + space, + cancel, + .. + } = *inputs; + let mut source_edits = LineEdits::default(); + let mut source = + self.shaped_paragraph(tree, root, space, ParagraphEdits::default(), cancel)?; + // The base build keeps what each space it was laid out in decided: + // laid out there again, the paragraph decides nothing and builds + // only what is kept. + let kept = std::rc::Rc::clone(&source.kept_decisions); + let key = BreakKey::new(space, inputs.band, &[]); + if let Some((decided, decided_bounds)) = kept.get(&key) { + bounds.intersect(&decided_bounds); + let mut built = if decided.builds_as(&source_edits) { + source + } else { + self.shaped_paragraph(tree, root, space, decided.edits(), cancel)? + }; + self.break_decided(inputs, &mut built, &decided.forced(), bounds); + return Ok(decided_paragraph(built)); + } + let mut pass_bounds = WidthBounds::new(space.inline_size); + // A pass over a build whose lines broke elsewhere than its copy's + // runs once more over that build, at most twice; then the build's + // own lines stand. + for attempt in 0..3 { + let trims_possible = *source.line_end_trims.get_or_init(|| { + line_end_trims_possible(&self.catalog, &source.layout, &source.text) + }); + let decides = !source.pair_trims.is_empty() + || !source.ruby_annotation_widths.is_empty() + || trims_possible; + let pass = Pass { + context: self, + inputs, + source: &source, + source_edits: &source_edits, + trims_possible, + }; + // Most paragraphs break into lines that decide nothing: those + // stand as the build broke them, and a pass decides only from + // the first line that does. + let first = if decides { + pass.first_decision(&mut pass_bounds) + } else { + None + }; + let (decided, ends) = match first { + Some(first) => { + let (decided, ends) = pass.decide(&mut pass_bounds, first)?; + (decided, Some(ends)) + } + None => (source_edits.clone(), None), + }; + let mut built = if decided.builds_as(&source_edits) { + source.clone() + } else { + self.shaped_paragraph(tree, root, space, decided.edits(), cancel)? + }; + self.break_decided(inputs, &mut built, &decided.forced(), bounds); + let ends_match = ends.is_none_or(|ends| { + built + .layout + .lines() + .map(|line| LineEnd { + range: line.text_range(), + reason: line.break_reason(), + }) + .eq(ends) + }); + if ends_match || attempt == 2 { + bounds.intersect(&pass_bounds); + kept.keep(key, decided, pass_bounds); + return Ok(decided_paragraph(built)); + } + source = built; + source_edits = decided; + } + unreachable!("the last attempt returns") + } + + /// Breaks `built` into its lines with `forced` ends, recording each + /// line's fit (and what pins the width) in `bounds`. + fn break_decided( + &self, + inputs: &LineEdgeInputs<'_>, + built: &mut ParagraphLayout, + forced: &[(usize, usize)], + bounds: &mut WidthBounds, + ) { + let LineEdgeInputs { space, band, .. } = *inputs; + built.kept_breaks.break_or_restore( + &mut built.layout, + BreakKey::new(space, band, forced), + |layout| { + #[cfg(test)] + self.counts.note_break(); + break_paragraph_lines(layout, space, band, forced); + }, + ); + if built.reads_space { + bounds.pin(); + } + let mut line_top = 0.0_f64; + for (index, line) in built.layout.lines().enumerate() { + record_line_fit( + bounds, + space, + band, + &line, + line_top, + if index == 0 { + built.first_line_indent + } else { + 0.0 + }, + ); + line_top += f64::from(line.metrics().line_height); + } + } +} + +/// Records a line's fit against the max advance the breaker gave it, and +/// pins the width when a band leaves the line no room. +fn record_line_fit( + bounds: &mut WidthBounds, + space: &ConstraintSpace, + band: Option, + line: &parley::layout::Line<'_, [u8; 4]>, + line_top: f64, + indent: f32, +) { + if let Some(band) = band.filter(|band| line_inside_band(line_top, *band)) { + if space.inline_size - band.left_inset - band.right_inset <= 0.0 { + bounds.pin(); + } + } + let limit = + (f64::from(line_max_advance(space, band, line_top)) + LINE_FIT_EPSILON) as f32 - indent; + let (floor, ceiling) = line.fit_range(); + bounds.fit_range(f64::from(floor), f64::from(ceiling), f64::from(limit)); +} + +fn decided_paragraph(built: ParagraphLayout) -> DecidedParagraph { + DecidedParagraph { + layout: built.layout, + alignment: built.alignment, + shifted_ranges: built.shifted_ranges, + first_line_indent: built.first_line_indent, + ruby_spreads: built.ruby_spreads, + ruby_spread_overhangs: built.ruby_spread_overhangs, + ruby_spread_overhangs_right: built.ruby_spread_overhangs_right, + ruby_base_shifts: built.ruby_base_shifts, + opener_halt_trims: built.opener_halt_trims, + inline_block_boxes: built.inline_block_boxes, + inline_block_baselines: built.inline_block_baselines, + image_edge_insets: built.image_edge_insets, + empty_box_struts: built.empty_box_struts, + spacing_edits: built.spacing_edits, + cluster_steps: built.cluster_steps, + } +} + +/// The decisions under way. +struct State { + edits: LineEdits, + rejected: Vec, + pending: Option, + /// Each overwritten cluster's advance in the source build. + originals: HashMap, +} + +impl Pass<'_> { + /// The first line of the source broken as its edits break it that + /// decides anything, when one does; every comparison is recorded in + /// `bounds`, so a paragraph whose lines decide nothing stands as broken. + fn first_decision(&self, bounds: &mut WidthBounds) -> Option { + let mut scanned = self.source.clone(); + self.context.break_decided( + self.inputs, + &mut scanned, + &self.source_edits.forced(), + bounds, + ); + let mut state = State { + edits: self.source_edits.clone(), + rejected: Vec::new(), + pending: None, + originals: HashMap::new(), + }; + let mut line_top = 0.0_f64; + for (index, line) in scanned.layout.lines().enumerate() { + if self + .decide_line(&scanned.layout, index, line_top, 0, &mut state, bounds) + .is_some() + { + return Some(index); + } + line_top += f64::from(line.metrics().line_height); + } + None + } + + /// Breaks the source's lines one at a time on a copy, deciding each + /// line's edge from line `first` on (the lines before it decide + /// nothing): the edits and forced ends decided, and where each line + /// ended. + fn decide( + &self, + bounds: &mut WidthBounds, + first: usize, + ) -> Result<(LineEdits, Vec), LayoutError> { + #[cfg(test)] + self.context.counts.note_pass(); + let LineEdgeInputs { + space, + band, + cancel, + .. + } = *self.inputs; + let mut state = State { + edits: self.source_edits.clone(), + rejected: Vec::new(), + pending: None, + originals: HashMap::new(), + }; + let mut copy = self.source.layout.clone(); + let mut breaker = copy.break_lines(); + breaker + .state_mut() + .set_layout_max_advance((space.inline_size + LINE_FIT_EPSILON) as f32); + let mut ends = Vec::new(); + let mut line_top = 0.0_f64; + let line_bound = self.source.text.len() + self.source.layout.inline_boxes().len() + 1; + // Decisions a line may take before it stands as broken: each one + // finalizes the line further, so real paragraphs stay far below. + const LINE_DECISION_LIMIT: usize = 64; + loop { + if cancel.is_cancelled() { + return Err(LayoutError::Cancelled); + } + let index = ends.len(); + let start = breaker.state().clone(); + let mut spread_rounds = 0_u32; + let mut decisions = 0_usize; + let line_end = loop { + breaker.revert_to(start.clone()); + set_next_line_width(&mut breaker, space, band); + let broken = match state.edits.forced.get(&index) { + Some(&end) => breaker.break_next_to(end).is_some(), + None => breaker.break_next().is_some(), + }; + if !broken { + break None; + } + // Read before the tentative next line: reverting past the + // last line clears it, and the breaker would then commit + // empty lines for ever. + let last = breaker.is_done(); + let after = breaker.state().clone(); + if index < first { + // Broken exactly as the source broke it: it decides + // nothing, and its fit is recorded. + break breaker.inspect(|layout| { + layout.get(index).map(|line| { + ( + LineEnd { + range: line.text_range(), + reason: line.break_reason(), + }, + f64::from(line.metrics().line_height), + last, + ) + }) + }); + } + set_next_line_width(&mut breaker, space, band); + let _ = breaker.break_next(); + let decision = breaker.inspect(|layout| { + self.record_fits(bounds, layout, index, line_top); + if decisions >= LINE_DECISION_LIMIT { + return None; + } + self.decide_line(layout, index, line_top, spread_rounds, &mut state, bounds) + }); + let Some(decision) = decision else { + let end = breaker.inspect(|layout| { + layout.get(index).map(|line| { + ( + LineEnd { + range: line.text_range(), + reason: line.break_reason(), + }, + f64::from(line.metrics().line_height), + last, + ) + }) + }); + breaker.revert_to(after); + break end; + }; + decisions += 1; + if matches!(decision, Decision::Spread(_)) { + spread_rounds += 1; + } + self.apply(decision, index, &mut state, &mut breaker); + }; + let Some((end, height, last)) = line_end else { + break; + }; + ends.push(end); + line_top += height; + if last { + break; + } + // Every line holds a cluster or a box but an empty last one, so + // more lines than that mean the breaking went wrong: stop rather + // than lay lines out for ever. + if ends.len() > line_bound { + return Err(LayoutError::Invalid(format!( + "line decisions broke {} lines out of {line_bound} possible", + ends.len() + ))); + } + } + drop(breaker); + Ok((state.edits, ends)) + } + + /// Records the fits of line `index` and the tentative line after it. + fn record_fits( + &self, + bounds: &mut WidthBounds, + layout: &parley::Layout<[u8; 4]>, + index: usize, + line_top: f64, + ) { + let LineEdgeInputs { space, band, .. } = *self.inputs; + let mut top = line_top; + for at in index..(index + 2).min(layout.len()) { + let Some(line) = layout.get(at) else { + break; + }; + let indent = if at == 0 { + self.source.first_line_indent + } else { + 0.0 + }; + record_line_fit(bounds, space, band, &line, top, indent); + top += f64::from(line.metrics().line_height); + } + } + + /// What line `index`'s edge decides, in the order the decisions take + /// one another into account: a pair the break separates, the pending + /// trim, a ruby split rewound, split segments spread, a closer the line + /// extends by, an item rewound. + fn decide_line( + &self, + layout: &parley::Layout<[u8; 4]>, + index: usize, + line_top: f64, + spread_rounds: u32, + state: &mut State, + bounds: &mut WidthBounds, + ) -> Option { + let LineEdgeInputs { space, band, .. } = *self.inputs; + let line = layout.get(index)?; + let next = layout.get(index + 1); + let text = &self.source.text; + let plan = &self.source.spacing_plan; + let edits = state.edits.edits(); + // A pair a line break separates keeps both glyphs at full width, + // exactly as the browser trims within lines only. + if let Some(next) = &next { + if let Some(trim) = plan.applied_pair_ending_at(next.text_range().start, edits) { + return Some(Decision::Suppress(trim.left_byte)); + } + } + if let Some(byte) = state.pending { + let trimmed_end = byte + text[byte..].chars().next().map_or(1, char::len_utf8); + let confirmed = line.text_range().end == trimmed_end + || next + .as_ref() + .is_some_and(|next| next.text_range().end == trimmed_end); + if !confirmed { + return Some(Decision::Reject(byte)); + } + state.pending = None; + } + let indent = if index == 0 { + self.source.first_line_indent + } else { + 0.0 + }; + let forced = state.edits.forced.contains_key(&index); + let last = next.is_none(); + if !forced && !last && !self.source.ruby_annotation_widths.is_empty() { + let max_advance = + f64::from(line_max_advance(space, band, line_top) - indent) + LINE_FIT_EPSILON; + if let Some(end) = self.ruby_split_rewind(layout, &line, max_advance, bounds) { + return Some(Decision::Force(end)); + } + } + if spread_rounds < 2 && !self.source.ruby_annotation_widths.is_empty() { + let spreads = self.split_spreads(layout, &line, &state.edits.split_spreads); + if !spreads.is_empty() { + return Some(Decision::Spread(spreads)); + } + } + if let Some(item) = self.opening_ruby(&line, &state.edits) { + return Some(Decision::Unhang(item)); + } + if forced || last || !self.trims_possible { + return None; + } + // The line-end extension measures openers whose pair a break + // suppressed back in their mid-line (trimmed) form. + let suppressed_openers = self.suppressed_openers(&state.edits, &line, next.as_ref()); + let max_advance = + line_max_advance(space, band, line_top) + LINE_FIT_EPSILON as f32 - indent; + let decided = |byte: usize| { + state.edits.end_trims.binary_search(&byte).is_ok() || state.rejected.contains(&byte) + }; + line_end_trim_candidate( + &self.context.catalog, + layout, + text, + index, + max_advance, + decided, + &suppressed_openers, + bounds, + ) + .map(|(byte, unsuppress)| Decision::Trim { byte, unsuppress }) + } + + /// The (opener byte, pair left byte) of each suppressed opener-halt pair + /// whose opener the candidate scan past this line may reach. + fn suppressed_openers( + &self, + edits: &LineEdits, + line: &parley::layout::Line<'_, [u8; 4]>, + next: Option<&parley::layout::Line<'_, [u8; 4]>>, + ) -> Vec<(usize, usize)> { + let text = &self.source.text; + let from = line.text_range().start; + let to = next.map_or(line.text_range().end, |next| next.text_range().end); + let suppressed = &edits.suppressed_pair_trims; + let first = suppressed.partition_point(|left| *left < from); + suppressed[first..] + .iter() + .take_while(|left| **left < to) + .filter_map(|&left_byte| { + let left = text[left_byte..].chars().next()?; + let right_byte = left_byte + left.len_utf8(); + let right = text[right_byte..].chars().next()?; + (cjk_punctuation_trim(left, right) == Some(TrimmedGlyph::Right)) + .then_some((right_byte, left_byte)) + }) + .collect() + } + + /// The whole ruby opening `line` while its column overhangs the text + /// before it: Blink overhangs only an item before the ruby on its own + /// line (`CanApplyStartOverhang` needs one), so a ruby opening a line + /// keeps its start inset inside the column. + fn opening_ruby( + &self, + line: &parley::layout::Line<'_, [u8; 4]>, + edits: &LineEdits, + ) -> Option { + let start = line.text_range().start; + let ranges = self.inputs.item_text_ranges; + let item = ranges.partition_point(|range| range.end <= start); + if ranges.get(item)?.start != start { + return None; + } + let carrier = self.source.spacing_plan.ruby_carrier(item)?; + (carrier.start_overhang > 0.0 && edits.unhung_starts.binary_search(&item).is_err()) + .then_some(item) + } + + /// The item containing byte `at` strictly inside it, if a text item + /// does: items tile the flow text in order. + fn item_around(&self, at: usize) -> Option { + let ranges = self.inputs.item_text_ranges; + let index = ranges.partition_point(|range| range.end <= at); + ranges + .get(index) + .is_some_and(|range| range.start < at && at < range.end) + .then_some(index) + } + + /// Ruby split-fit: a soft break inside a ruby base is legal only while + /// the first segment still fits carrying its WHOLE annotation — the + /// segment widens to at least the annotation's advance (measured: + /// 608px of text plus 异 at 16px fit a 627.2px line, but the segment + /// carries Talent at 23px and Blink sends the ruby down; 黄金妖|精 + /// stays split because 黄金妖 at 48px covers Leprechaun's 44px). An + /// overflowing split rewinds the line to the item start. + fn ruby_split_rewind( + &self, + layout: &parley::Layout<[u8; 4]>, + line: &parley::layout::Line<'_, [u8; 4]>, + max_advance: f64, + bounds: &mut WidthBounds, + ) -> Option { + if line.break_reason() != parley::layout::BreakReason::Regular { + return None; + } + let range = line.text_range(); + let item = self.item_around(range.end)?; + let annotation_width = *self.source.ruby_annotation_widths.get(&item)?; + let item_range = self.inputs.item_text_ranges[item].clone(); + let item_start = item_range.start; + // The split's first segment must START on this line; a base already + // split earlier has nothing to rewind. + if item_start < range.start { + return None; + } + let text = &self.source.text; + let mut segment_advance = 0.0_f64; + let mut cluster = parley::layout::Cluster::from_byte_index(layout, item_start); + while let Some(current) = cluster { + if current.text_range().start >= range.end { + break; + } + segment_advance += f64::from(current.advance()); + cluster = current.next_logical(); + } + // A multi-word annotation splits at its spaces and only the words + // allocated to THIS segment (by character-midpoint position) must + // fit over it — 正|规勇者 under "Legal Brave" keeps the split + // because 正 carries only Legal (measured matrix). + let segment_annotation_width = match self.annotation_text(item) { + Some(annotation) => { + let total_chars = text + .get(item_range.clone()) + .map_or(0, |base| base.chars().count()); + let segment_chars = text + .get(item_start..range.end) + .map_or(0, |segment| segment.chars().count()); + if total_chars == 0 { + annotation_width + } else { + let ratio = segment_chars as f64 / total_chars as f64; + let allocated = rito_fragment::allocate_ruby_annotation(annotation, 0.0, ratio); + if allocated == annotation { + annotation_width + } else if allocated.is_empty() { + 0.0 + } else { + // Approximate the allocated words' advance by + // character share — exact enough for the fit + // decision, and both ends stay measurement-free. + annotation_width * allocated.chars().count() as f64 + / annotation.chars().count().max(1) as f64 + } + } + } + None => annotation_width, + }; + // The segment's box presses its allocated annotation's advance on + // the line, minus the RUBY's own spread overhang — an unspread ruby + // (annotation narrower than the whole base) overhangs nothing, so + // its full allocated advance presses (measured: 异/Talent rewinds at + // 19.875 where the segment-local half-excess would have squeaked + // by; spread 咒/Thaumaturgy keeps its split at 42.95 − 2.74). + let overhang = self + .source + .ruby_spread_overhangs + .get(&item) + .copied() + .unwrap_or(0.0); + let segment_box = (segment_annotation_width - overhang).max(segment_advance); + if segment_box <= segment_advance + LINE_FIT_EPSILON { + return None; + } + let metrics = line.metrics(); + let natural = f64::from(metrics.advance) - f64::from(metrics.trailing_whitespace); + if bounds.fits(natural - segment_advance + segment_box, max_advance) { + return None; + } + (item_start > range.start).then_some(item_start) + } + + /// A ruby base SPLIT across lines spreads each on-line segment to its + /// allocated annotation words' advance (measured on a walk-mirrored + /// page: 'Leprechaun' over the split 黄金|妖精 spreads the first + /// segment's 黄 to a 21.77px box — 16 + excess/(2·n) space-around + /// shares — while the whole-base test saw a narrow annotation and left + /// it packed): the spreads this line's split segments take. + fn split_spreads( + &self, + layout: &parley::Layout<[u8; 4]>, + line: &parley::layout::Line<'_, [u8; 4]>, + decided: &[(Range, f32)], + ) -> Vec<(Range, f32)> { + let text = &self.source.text; + let line_range = line.text_range(); + let ranges = self.inputs.item_text_ranges; + let mut spreads: Vec<(Range, f32)> = Vec::new(); + // Only a base the line splits qualifies (whole bases took the + // shaping-time spread law already): the items across its start and + // its end. + let mut items: Vec = [ + self.item_around(line_range.start), + self.item_around(line_range.end), + ] + .into_iter() + .flatten() + .collect(); + items.dedup(); + for item in items { + let Some(annotation_width) = self.source.ruby_annotation_widths.get(&item).copied() + else { + continue; + }; + let item_range = ranges[item].clone(); + let seg_start = item_range.start.max(line_range.start); + let seg_end = item_range.end.min(line_range.end); + if seg_end <= seg_start { + continue; + } + if decided + .iter() + .chain(spreads.iter()) + .any(|(range, _)| range.start == seg_start) + { + continue; + } + let Some(annotation_text) = self.annotation_text(item) else { + continue; + }; + let total_chars = text + .get(item_range.clone()) + .map_or(0, |base| base.chars().count()); + if total_chars == 0 { + continue; + } + let before = text + .get(item_range.start..seg_start) + .map_or(0.0, |t| t.chars().count() as f64); + let through = text + .get(item_range.start..seg_end) + .map_or(0.0, |t| t.chars().count() as f64); + let allocated = rito_fragment::allocate_ruby_annotation( + annotation_text, + before / total_chars as f64, + if seg_end >= item_range.end { + f64::INFINITY + } else { + through / total_chars as f64 + }, + ); + if allocated.is_empty() { + continue; + } + let allocated_width = annotation_width * allocated.chars().count() as f64 + / annotation_text.chars().count().max(1) as f64; + let mut segment_advance = 0.0_f64; + let mut cluster = parley::layout::Cluster::from_byte_index(layout, seg_start); + while let Some(current) = cluster { + if current.text_range().start >= seg_end { + break; + } + segment_advance += f64::from(current.advance()); + cluster = current.next_logical(); + } + let excess = allocated_width - segment_advance; + if excess <= 0.01 { + continue; + } + let seg_chars = text + .get(seg_start..seg_end) + .map_or(0, |t| t.chars().count()); + if seg_chars == 0 { + continue; + } + let n = seg_chars as f64; + let gap = excess / n; + let last_cluster_start = text + .get(seg_start..seg_end) + .and_then(|t| t.char_indices().next_back()) + .map_or(seg_start, |(offset, _)| seg_start + offset); + if seg_chars >= 2 && last_cluster_start > seg_start { + spreads.push((seg_start..last_cluster_start, gap as f32)); + } + // Edge share carried by the segment's last cluster (half a + // share per side). + spreads.push((last_cluster_start..seg_end, (excess / (2.0 * n)) as f32)); + } + spreads + } + + fn annotation_text(&self, item: usize) -> Option<&str> { + let FormattingNodeContent::InlineFlow { items } = + &self.inputs.tree.node(self.inputs.root).content + else { + return None; + }; + match items.get(item) { + Some(InlineItem::Text { + ruby_annotation: Some(annotation), + .. + }) => Some(annotation.text.as_str()), + _ => None, + } + } + + /// Takes `decision` for line `index`, overwriting the copy's advances + /// of every cluster whose spacing it changes. + fn apply( + &self, + decision: Decision, + index: usize, + state: &mut State, + breaker: &mut parley::BreakLines<'_, [u8; 4]>, + ) { + let plan = &self.source.spacing_plan; + let mut touched: Vec = Vec::new(); + match decision { + Decision::Suppress(left) => { + insert_sorted(&mut state.edits.suppressed_pair_trims, left); + state.edits.forced.remove(&index); + touched.extend(plan.pair_edit_start(left)); + } + Decision::Reject(byte) => { + remove_sorted(&mut state.edits.end_trims, byte); + state.rejected.push(byte); + state.pending = None; + touched.push(byte); + } + Decision::Force(end) => { + state.edits.forced.insert(index, end); + } + Decision::Unhang(item) => { + insert_sorted(&mut state.edits.unhung_starts, item); + if let Some(carrier) = plan.ruby_carrier(item) { + touched.push(carrier.range.start); + } + } + Decision::Spread(spreads) => { + let text = &self.source.text; + for (range, _) in &spreads { + touched.extend( + text[range.clone()] + .char_indices() + .map(|(at, _)| range.start + at), + ); + } + state.edits.split_spreads.extend(spreads); + } + Decision::Trim { byte, unsuppress } => { + insert_sorted(&mut state.edits.end_trims, byte); + for left in unsuppress { + remove_sorted(&mut state.edits.suppressed_pair_trims, left); + touched.extend(plan.pair_edit_start(left)); + } + state.pending = Some(byte); + state.edits.forced.remove(&index); + touched.push(byte); + } + } + let text = &self.source.text; + let source_edits = self.source_edits.edits(); + for byte in touched { + let Some(cluster) = breaker.inspect(|layout| layout.cluster_starting_at(byte)) else { + continue; + }; + let original = *state + .originals + .entry(cluster) + .or_insert_with(|| breaker.cluster_advance(cluster)); + let edits = state.edits.edits(); + let spacing = plan.letter_spacing_at(text, byte, edits) + - plan.letter_spacing_at(text, byte, source_edits); + let halt = plan.halt_half_at(byte, source_edits).unwrap_or(0.0) + - plan.halt_half_at(byte, edits).unwrap_or(0.0); + breaker.set_cluster_advance(cluster, original + spacing + halt); + } + } +} diff --git a/crates/rito-inline/src/line_decisions/mod.rs b/crates/rito-inline/src/line_decisions/mod.rs new file mode 100644 index 000000000..1daaed2f8 --- /dev/null +++ b/crates/rito-inline/src/line_decisions/mod.rs @@ -0,0 +1,142 @@ +//! Where a paragraph's lines end once its line edges are decided: the +//! line-end trims Chromium extends a line by, the pair trims a break +//! separates, the spreads of a ruby base split across lines and the lines +//! a split ruby base rewinds. Each decision belongs to one line's +//! edge, as Blink makes it while breaking that line (`ShapingLineBreaker`). + +mod forward; + +pub(crate) use forward::KeptDecisions; + +use crate::*; +use rito_fragment::FloatBand; + +/// Blink accepts a line that overflows its available width by up to +/// one LayoutUnit: NGLineBreaker::CanFitOnLine compares against +/// available_width_.AddEpsilon(). Measured on the Tinos body idiom: +/// a 626.695-wide line fits a 626.6875 column (threshold scanned to +/// the 1/64), so a strict compare here wrapped one word early and +/// drifted whole paragraphs. The epsilon widens only the FIT — the +/// justify target below keeps the true width, exactly as Blink +/// justifies to the unwidened line box. +pub(crate) const LINE_FIT_EPSILON: f64 = 1.0 / 64.0; + +/// Lines break through one manual loop so a specific line index can +/// be FORCED to end before a text index: a ruby base split where its +/// first segment cannot carry its annotation rewinds the whole base to +/// the next line, and `break_next_to` reproduces that rewind, holding +/// whatever precedes the item (an inline image included). +pub(crate) fn break_paragraph_lines( + layout: &mut parley::Layout<[u8; 4]>, + space: &ConstraintSpace, + band: Option, + forced: &[(usize, usize)], +) { + if band.is_none() && forced.is_empty() { + layout.break_all_lines(Some((space.inline_size + LINE_FIT_EPSILON) as f32)); + return; + } + let mut breaker = layout.break_lines(); + breaker + .state_mut() + .set_layout_max_advance((space.inline_size + LINE_FIT_EPSILON) as f32); + let mut index = 0usize; + loop { + set_next_line_width(&mut breaker, space, band); + let forced_end = forced + .iter() + .find(|(line, _)| *line == index) + .map(|(_, end)| *end); + let progressed = match forced_end { + Some(end) => breaker.break_next_to(end).is_some(), + None => breaker.break_next().is_some(), + }; + if !progressed { + break; + } + index += 1; + } + breaker.finish(); +} + +/// Whether a line whose top is `line_top` lies beside the float band: the +/// one rule the breaker and every decision about the line read. +pub(crate) fn line_inside_band(line_top: f64, band: FloatBand) -> bool { + line_top < band.bottom +} + +/// Gives the next line the breaker lays its advance and start: the band's +/// width and offset while the line's top sits inside the band, the full +/// inline size below it. Every line gets them explicitly: a forced break +/// leaves breaker state the next natural break must not inherit. +pub(crate) fn set_next_line_width( + breaker: &mut parley::BreakLines<'_, [u8; 4]>, + space: &ConstraintSpace, + band: Option, +) { + if let Some(band) = band { + let band_inline_size = (space.inline_size - band.left_inset - band.right_inset).max(0.0); + let (advance, offset) = if line_inside_band(breaker.committed_y(), band) { + (band_inline_size, band.left_inset) + } else { + (space.inline_size, 0.0) + }; + let state = breaker.state_mut(); + state.set_line_max_advance((advance + LINE_FIT_EPSILON) as f32); + state.set_line_x(offset as f32); + } else { + breaker + .state_mut() + .set_line_max_advance((space.inline_size + LINE_FIT_EPSILON) as f32); + } +} + +/// The max advance the breaker gave a line, reconstructed from the +/// same rule [`set_next_line_width`] applies: band width while the line's +/// top sits inside the band, the full inline size below it. +pub(crate) fn line_max_advance( + space: &ConstraintSpace, + band: Option, + line_top: f64, +) -> f32 { + match band { + Some(band) if line_inside_band(line_top, band) => { + (space.inline_size - band.left_inset - band.right_inset).max(0.0) as f32 + } + _ => space.inline_size as f32, + } +} + +/// What deciding a paragraph's line edges reads besides the paragraph. +#[derive(Clone, Copy)] +pub(crate) struct LineEdgeInputs<'a> { + pub(crate) tree: &'a FormattingTree, + pub(crate) root: FormattingNodeId, + pub(crate) space: &'a ConstraintSpace, + /// The float band the lines wrap around, when it narrows any. + pub(crate) band: Option, + /// Each item's byte range in the flow text (an atom's is empty). + pub(crate) item_text_ranges: &'a [std::ops::Range], + pub(crate) cancel: &'a CancelFlag, +} + +/// The paragraph built with its decided edges and broken into its lines, +/// with everything the line assembly reads from the build. +pub(crate) struct DecidedParagraph { + pub(crate) layout: parley::Layout<[u8; 4]>, + pub(crate) alignment: parley::Alignment, + pub(crate) shifted_ranges: Vec<(std::ops::Range, f64)>, + pub(crate) first_line_indent: f32, + pub(crate) ruby_spreads: std::collections::HashMap, + pub(crate) ruby_spread_overhangs: std::collections::HashMap, + pub(crate) ruby_spread_overhangs_right: std::collections::HashMap, + pub(crate) ruby_base_shifts: std::collections::HashMap, + pub(crate) opener_halt_trims: Vec<(std::ops::Range, f64)>, + pub(crate) inline_block_boxes: + std::collections::HashMap, + pub(crate) inline_block_baselines: std::collections::HashMap, + pub(crate) image_edge_insets: std::collections::HashMap, + pub(crate) empty_box_struts: Vec<(usize, rito_style_contract::StyleId, f64)>, + pub(crate) spacing_edits: SpacingEdits, + pub(crate) cluster_steps: std::rc::Rc>, +} diff --git a/crates/rito-inline/src/line_items.rs b/crates/rito-inline/src/line_items.rs index 8621c85c3..5792f9c09 100644 --- a/crates/rito-inline/src/line_items.rs +++ b/crates/rito-inline/src/line_items.rs @@ -54,14 +54,16 @@ pub(crate) fn line_items<'a>( kind: inline_box.kind, }), }); - placed.chain(split) + // A box's start and end sizes are neither glyphs nor atoms: they only + // move what follows them. + placed + .chain(split) + .filter(|item| !matches!(item, LineItemView::Atom(inline_box) if inline_box.kind.is_tag())) } -/// The inline item each glyph run and inline box of a line belongs to, in -/// line order: a glyph run by its brush, an inline box by `u32::MAX - id`. -pub(crate) fn line_item_keys<'a>(line: &Line<'a, [u8; 4]>) -> impl Iterator + 'a { - line.style_runs().map(|item| match item { - LineStyleRun::Glyphs { style, .. } => u32::from_le_bytes(style.brush), - LineStyleRun::InlineBox(inline_box) => u32::MAX - inline_box.id as u32, +/// Whether the line holds an atomic inline (an image or an inline block). +pub(crate) fn line_has_atom(line: &Line<'_, [u8; 4]>) -> bool { + line.style_runs().any(|item| { + matches!(item, LineStyleRun::InlineBox(inline_box) if inline_box.kind == parley::InlineBoxKind::InFlow) }) } diff --git a/crates/rito-inline/src/line_metrics.rs b/crates/rito-inline/src/line_metrics.rs index 07d14c23c..453133c05 100644 --- a/crates/rito-inline/src/line_metrics.rs +++ b/crates/rito-inline/src/line_metrics.rs @@ -53,6 +53,78 @@ pub(crate) struct ShiftProbe { pub line_height: Option, } +/// The baseline a line's font metrics hang from: the alphabetic baseline +/// of horizontal lines, or the central baseline an upright vertical line +/// centres its glyphs on (Blink's `GetFontBaseline`: every vertical line +/// but a sideways one). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum LineBaseline { + Alphabetic, + Central, +} + +impl LineBaseline { + pub(crate) fn of(style: &InlineFormattingStyle) -> Self { + let horizontal = style.bidi.writing_mode == WritingMode::HorizontalTopToBottom + || style.bidi.text_orientation == TextOrientation::Sideways; + if horizontal { + Self::Alphabetic + } else { + Self::Central + } + } + + /// A font's whole-pixel ascent and descent measured from this + /// baseline: the central baseline halves the whole-pixel height, the + /// larger half above (`FontMetrics::Ascent(kCentralBaseline)`). + pub(crate) fn split(self, ascent: f64, descent: f64) -> (f64, f64) { + match self { + Self::Alphabetic => (ascent, descent), + Self::Central => { + let below = ((ascent + descent) / 2.0).floor(); + (ascent + descent - below, below) + } + } + } + + /// The em box around this baseline at `size`: a font's typo ascent and + /// descent normalized to the em for the alphabetic baseline, the em + /// (on the 1/64 grid) halved around the central one + /// (`SimpleFontData::NormalizedTypoAscentAndDescent`). + pub(crate) fn em_box(self, font_ref: &skrifa::FontRef<'_>, size: f64) -> (f64, f64) { + match self { + Self::Alphabetic => normalized_typo_height(font_ref, size), + Self::Central => { + let em = layout_unit(size); + let below = (em * 32.0).floor() / 64.0; + (em - below, below) + } + } + } + + /// How far towards the line-over side an em box measured around this + /// baseline moves: Blink's `ComputeEmHeight` places it from the text + /// box by the primary font's unrounded ascent, so around the central + /// baseline it rides the half pixel an odd whole-pixel height leaves + /// between the box's whole-pixel ascent and half its height. + pub(crate) fn em_shift(self, ascent: f64, descent: f64) -> f64 { + match self { + Self::Alphabetic => 0.0, + Self::Central => self.split(ascent, descent).0 - (ascent + descent) / 2.0, + } + } + + /// The cap the primary font puts on an em height: its whole-pixel + /// ascent and descent, or half its whole-pixel height each side of + /// the central baseline (`GetFloatFontHeight`). + pub(crate) fn em_cap(self, ascent: f64, descent: f64) -> (f64, f64) { + match self { + Self::Alphabetic => (ascent, descent), + Self::Central => ((ascent + descent) / 2.0, (ascent + descent) / 2.0), + } + } +} + /// Quantizes a size ratio for a probe key. pub(crate) fn probe_ratio(ratio: f64) -> u32 { (ratio * 10_000.0).round() as u32 @@ -70,10 +142,10 @@ pub(crate) struct NormalLineMetric { pub height: f64, /// Baseline offset from the line box top. pub baseline: f64, - /// The serving font's whole-pixel (ascent, descent): the basis Blink - /// places fixed line-height baselines and selection boxes with. It - /// differs from the normal-line envelope whenever the font carries a - /// line gap. + /// The serving font's whole-pixel (ascent, descent) from the line's + /// baseline: the basis Blink places fixed line-height baselines and + /// selection boxes with. It differs from the normal-line envelope + /// whenever the font carries a line gap. pub grid: (f64, f64), } @@ -95,14 +167,14 @@ impl NormalLineMetric { } /// A face the collection serves: its blob and index. -#[derive(Clone)] +#[derive(Clone, Debug)] pub(crate) struct Face { - blob: parley::fontique::Blob, - index: u32, + pub(crate) blob: parley::fontique::Blob, + pub(crate) index: u32, } impl Face { - fn font_ref(&self) -> Option> { + pub(crate) fn font_ref(&self) -> Option> { skrifa::FontRef::from_index(self.blob.as_ref(), self.index).ok() } @@ -111,22 +183,30 @@ impl Face { } /// Whole-pixel ascent, descent and line gap at `size`. - fn rounded(&self, size: f64) -> (f64, f64, f64) { + pub(crate) fn rounded(&self, size: f64) -> (f64, f64, f64) { self.font_ref() .map_or((0.0, 0.0, 0.0), |font| rounded_metrics(&font, size)) } - /// The normal-line (ascent, descent): the line gap split with the - /// floored half on top. - fn normal(&self, size: f64) -> (f64, f64) { - let (ascent, descent, gap) = self.rounded(size); + /// The whole-pixel (ascent, descent) from `baseline`: the text box a + /// run in this face takes. + fn text_box(&self, size: f64, baseline: LineBaseline) -> (f64, f64) { + let (ascent, descent, _) = self.rounded(size); + baseline.split(ascent, descent) + } + + /// The normal-line (ascent, descent) from `baseline`: the line gap + /// split with the floored half on top. + fn normal(&self, size: f64, baseline: LineBaseline) -> (f64, f64) { + let (_, _, gap) = self.rounded(size); + let (ascent, descent) = self.text_box(size, baseline); let top = (gap / 2.0).floor(); (ascent + top, descent + gap - top) } - fn normalized_em(&self, size: f64) -> (f64, f64) { + fn normalized_em(&self, size: f64, baseline: LineBaseline) -> (f64, f64) { self.font_ref() - .map_or((0.0, 0.0), |font| normalized_typo_height(&font, size)) + .map_or((0.0, 0.0), |font| baseline.em_box(&font, size)) } } @@ -148,7 +228,12 @@ impl ParleyInlineContext { size: f64, probe: &LineProbe, ) -> Option { - let key = (self.family_key(style), size.to_bits(), probe.clone()); + let key = ( + self.family_key(style), + size.to_bits(), + LineBaseline::of(style), + probe.clone(), + ); if let Some(metric) = self.line_metrics.borrow().get(&key) { return *metric; } @@ -164,26 +249,24 @@ impl ParleyInlineContext { probe: &LineProbe, ) -> Option { let primary = self.primary_face(style)?; - let grid = |face: &Face| { - let (ascent, descent, _) = face.rounded(size); - (ascent, descent) - }; + let baseline = LineBaseline::of(style); + let grid = |face: &Face| face.text_box(size, baseline); let (above, below, grid) = match probe { LineProbe::Strut => { - let (above, below) = primary.normal(size); + let (above, below) = primary.normal(size, baseline); (above, below, grid(&primary)) } LineProbe::Text(character) => { let face = self.char_face(style, size, false, *character, &primary); - let (above, below) = envelope(&[&primary, &face], size); + let (above, below) = envelope(&[&primary, &face], size, baseline); (above, below, grid(&face)) } LineProbe::Ruby(ruby) => { - let (above, below) = self.ruby_line(style, size, &primary, ruby); + let (above, below) = self.ruby_line(style, size, &primary, ruby, baseline); (above, below, grid(&primary)) } LineProbe::Shifted(shift) => { - let (above, below) = self.shifted_line(style, size, &primary, shift); + let (above, below) = self.shifted_line(style, size, &primary, shift, baseline); (above, below, grid(&primary)) } }; @@ -210,6 +293,7 @@ impl ParleyInlineContext { size: f64, primary: &Face, ruby: &RubyProbe, + baseline: LineBaseline, ) -> (f64, f64) { let (base_text, trailing) = match (ruby.latin_base, ruby.two_line) { (true, _) => ("ab ab", "ab"), @@ -226,14 +310,15 @@ impl ParleyInlineContext { (true, false) => "an", }; let annotation_faces = self.used_faces(style, annotation_size, false, annotation_text); - let base_em_ascent = em_height(&with_primary(&base_faces, None), primary, size).0; + let base_em_ascent = em_height(&with_primary(&base_faces, None), primary, size, baseline).0; let (em_ascent, em_descent) = em_height( &annotation_faces.iter().collect::>(), primary, annotation_size, + baseline, ); let annotation_top = base_em_ascent + em_ascent + em_descent; - let (above, below) = envelope(&with_primary(&line_faces, Some(primary)), size); + let (above, below) = envelope(&with_primary(&line_faces, Some(primary)), size, baseline); if !ruby.two_line { return (above.max(annotation_top), below); } @@ -242,14 +327,21 @@ impl ParleyInlineContext { } else { "\u{4E2D}\u{4E2D}\u{4E2D}\u{4E2D}" }); - let (previous_above, previous_below) = - envelope(&with_primary(&previous_faces, Some(primary)), size); + let (previous_above, previous_below) = envelope( + &with_primary(&previous_faces, Some(primary)), + size, + baseline, + ); let previous_line = previous_above + previous_below; - let primary_descent = primary.rounded(size).1; + let primary_descent = primary.text_box(size, baseline).1; + let primary_shift = { + let (ascent, descent, _) = primary.rounded(size); + baseline.em_shift(ascent, descent) + }; let reusable = previous_faces .iter() .map(|face| { - (primary_descent - face.normalized_em(size).1) + (primary_descent - face.normalized_em(size, baseline).1 + primary_shift) .max(0.0) .floor() }) @@ -274,15 +366,17 @@ impl ParleyInlineContext { size: f64, primary: &Face, shift: &ShiftProbe, + baseline: LineBaseline, ) -> (f64, f64) { let line_height = shift .line_height .map(|bits| layout_unit(f64::from_bits(bits))); let text = self.char_face(style, size, false, '\u{4E2D}', primary); - let (root_above, root_below) = inline_box(primary, &text, size, line_height); + let (root_above, root_below) = inline_box(primary, &text, size, line_height, baseline); let span_size = size * f64::from(shift.ratio) / 10_000.0; let marker = self.char_face(style, span_size, true, '\u{2460}', primary); - let (span_above, span_below) = inline_box(primary, &marker, span_size, line_height); + let (span_above, span_below) = + inline_box(primary, &marker, span_size, line_height, baseline); let parent = layout_unit(size); let offset = if shift.superscript { -(layout_unit_floor(parent / 3.0) + 1.0) @@ -367,27 +461,28 @@ fn with_primary<'a>(faces: &'a [Face], primary: Option<&'a Face>) -> Vec<&'a Fac } /// The greatest normal-line ascent and descent among `faces`. -fn envelope(faces: &[&Face], size: f64) -> (f64, f64) { +fn envelope(faces: &[&Face], size: f64, baseline: LineBaseline) -> (f64, f64) { faces .iter() .fold((0.0_f64, 0.0_f64), |(above, below), face| { - let (ascent, descent) = face.normal(size); + let (ascent, descent) = face.normal(size, baseline); (above.max(ascent), below.max(descent)) }) } -/// Blink's `ComputeEmHeight`: the faces' normalized em ascents and -/// descents united, ceiled to whole pixels, capped by the primary font's -/// whole-pixel ascent and descent. -fn em_height(faces: &[&Face], primary: &Face, size: f64) -> (f64, f64) { +/// Blink's `ComputeEmHeight`: the faces' em boxes around `baseline` +/// united, ceiled to whole pixels, capped by the primary font. +fn em_height(faces: &[&Face], primary: &Face, size: f64, baseline: LineBaseline) -> (f64, f64) { let (ascent, descent) = faces.iter().fold((0.0_f64, 0.0_f64), |(a, d), face| { - let (ascent, descent) = face.normalized_em(size); + let (ascent, descent) = face.normalized_em(size, baseline); (a.max(ascent), d.max(descent)) }); let (primary_ascent, primary_descent, _) = primary.rounded(size); + let (cap_ascent, cap_descent) = baseline.em_cap(primary_ascent, primary_descent); + let shift = baseline.em_shift(primary_ascent, primary_descent); ( - ascent.ceil().min(primary_ascent), - descent.ceil().min(primary_descent), + ascent.ceil().min(cap_ascent) + shift, + descent.ceil().min(cap_descent) - shift, ) } @@ -395,15 +490,22 @@ fn em_height(faces: &[&Face], primary: &Face, size: f64) -> (f64, f64) { /// font's whole-pixel ascent and descent with the leading of the used /// line-height split floored on top; under `normal` the font its text /// used joins with its own line gap as leading. -fn inline_box(primary: &Face, text: &Face, size: f64, line_height: Option) -> (f64, f64) { - let (ascent, descent, gap) = primary.rounded(size); +fn inline_box( + primary: &Face, + text: &Face, + size: f64, + line_height: Option, + baseline: LineBaseline, +) -> (f64, f64) { + let (_, _, gap) = primary.rounded(size); + let (ascent, descent) = primary.text_box(size, baseline); let height = line_height.unwrap_or(ascent + descent + gap); let top = ((height - (ascent + descent)) / 2.0).floor(); let (above, below) = (ascent + top, height - ascent - top); if line_height.is_some() { return (above, below); } - let (text_above, text_below) = text.normal(size); + let (text_above, text_below) = text.normal(size, baseline); (above.max(text_above), below.max(text_below)) } diff --git a/crates/rito-inline/src/marker.rs b/crates/rito-inline/src/marker.rs index ce27c9cdf..5f9fd8d02 100644 --- a/crates/rito-inline/src/marker.rs +++ b/crates/rito-inline/src/marker.rs @@ -37,7 +37,9 @@ pub(crate) fn list_marker_geometry( Some(InlineItem::Text { style, .. }) | Some(InlineItem::Image { style, .. }) | Some(InlineItem::InlineBlock { style, .. }) - | Some(InlineItem::EmptyBox { style, .. }) => *style, + | Some(InlineItem::EmptyBox { style, .. }) + | Some(InlineItem::OpenTag { style }) + | Some(InlineItem::CloseTag { style }) => *style, None => return None, }, }; diff --git a/crates/rito-inline/src/paragraph.rs b/crates/rito-inline/src/paragraph.rs index 9f8b6e008..5a7631aed 100644 --- a/crates/rito-inline/src/paragraph.rs +++ b/crates/rito-inline/src/paragraph.rs @@ -44,12 +44,12 @@ impl ParleyInlineContext { node, &build_space, PercentageImageSizing::Intrinsic, - edits.end_trims, - edits.suppressed_pair_trims, - edits.split_spreads, + edits, cancel, )?; built.reads_space = build_space.was_read(); + #[cfg(test)] + self.counts.note_build(); self.shaped.borrow_mut().insert(key, &build_space, &built); Ok(built) } @@ -61,9 +61,7 @@ impl ParleyInlineContext { node: FormattingNodeId, space: &BuildSpace, percentage_images: PercentageImageSizing, - end_trims: &[usize], - suppressed_pair_trims: &[usize], - split_spread_edits: &[(std::ops::Range, f32)], + edits: ParagraphEdits<'_>, cancel: &CancelFlag, ) -> Result { let FormattingNodeContent::InlineFlow { items } = &tree.node(node).content else { @@ -82,7 +80,9 @@ impl ParleyInlineContext { let mut text = String::new(); let mut runs = Vec::with_capacity(items.len()); let mut shifted_ranges: Vec<(std::ops::Range, f64)> = Vec::new(); - let mut image_boxes = Vec::new(); + // Atoms and box edges, in content order (the builder keeps the + // order of boxes at one index). + let mut inline_boxes = Vec::new(); // Laid-out inline-block atoms by item index: the mini paragraph's // baseline (its LAST line's, from the box top) and its fragment, // emitted at the inline box's position during line assembly. @@ -102,13 +102,6 @@ impl ParleyInlineContext { // leaded envelope around its shifted baseline joins the metrics // of whichever line the offset falls on. let mut empty_box_struts: Vec<(usize, rito_style_contract::StyleId, f64)> = Vec::new(); - // Blink consults its pair-preference table only under - // `word-break: normal`; `break-all`/`keep-all` change the break - // opportunities the table would otherwise veto. - let mut chromium_tailoring = true; - let mut break_all = false; - let mut strict_kinsoku = false; - let mut break_anywhere = false; for (item_index, item) in items.iter().enumerate() { match item { InlineItem::Text { @@ -126,18 +119,6 @@ impl ParleyInlineContext { .inline .style(*style) .map_err(|error| LayoutError::Invalid(error.to_string()))?; - if style.text_flow.word_break != rito_style_contract::WordBreak::Normal { - chromium_tailoring = false; - } - if style.text_flow.word_break == rito_style_contract::WordBreak::BreakAll { - break_all = true; - } - if style.text_flow.line_break == rito_style_contract::LineBreak::Strict { - strict_kinsoku = true; - } - if style.text_flow.line_break == rito_style_contract::LineBreak::Anywhere { - break_anywhere = true; - } runs.push((start..text.len(), style, item_index)); } InlineItem::Image { .. } | InlineItem::InlineBlock { .. } => { @@ -153,7 +134,7 @@ impl ParleyInlineContext { inline_block_baselines.insert(block_node, baseline); inline_block_boxes.insert(item_index as u64, (baseline, root_box)); } - image_boxes.push(InlineBox { + inline_boxes.push(InlineBox { id: item_index as u64, kind: InlineBoxKind::InFlow, index: text.len(), @@ -167,6 +148,32 @@ impl ParleyInlineContext { } => { empty_box_struts.push((text.len(), *style, *baseline_shift_px)); } + InlineItem::OpenTag { style } | InlineItem::CloseTag { style } => { + let closes = matches!(item, InlineItem::CloseTag { .. }); + let style = styles + .inline + .style(*style) + .map_err(|error| LayoutError::Invalid(error.to_string()))?; + // Blink's open (close) tag takes the box's margin, + // border and padding at the edge its lines start (end) + // at, percentages against the containing block. + let width = style + .inline_edge(closes) + .size(space.margin_basis().map(|basis| basis as f32)); + if width != 0.0 { + inline_boxes.push(InlineBox { + id: TAG_BOX_ID | item_index as u64, + kind: if closes { + InlineBoxKind::CloseTag + } else { + InlineBoxKind::OpenTag + }, + index: text.len(), + width, + height: 0.0, + }); + } + } } } @@ -212,10 +219,19 @@ impl ParleyInlineContext { let mut ruby_annotation_widths: std::collections::HashMap = std::collections::HashMap::new(); let mut ruby_spread_edits: Vec<(std::ops::Range, f32)> = Vec::new(); + // Each spread ruby's edge carrier, priced with and without its start + // overhang: the line loop drops the overhang of a ruby that opens a + // line (Blink's CanApplyStartOverhang needs an item before it on + // the line). + let mut ruby_carriers: Vec = Vec::new(); // Per item: the paint-side right shift of the base glyphs inside // the column (the start inset that could not overhang). - let mut ruby_center_shifts: std::collections::HashMap = + let mut ruby_base_shifts: std::collections::HashMap = std::collections::HashMap::new(); + // Every short ruby's base: one unit a line never breaks inside + // (Blink's `IsMonolithicRuby`: a base of at most four glyphs under + // an annotation of at most eight). + let mut monolithic_rubies: Vec> = Vec::new(); for (range, style, item_index) in &runs { let Some(InlineItem::Text { ruby_annotation: Some(annotation), @@ -224,17 +240,22 @@ impl ParleyInlineContext { else { continue; }; - if annotation.text.is_empty() || range.is_empty() { + if range.is_empty() { continue; } let base_text = &text[range.clone()]; - if base_text.chars().count() == 0 { + let annotation_size = style.font.size.get() * annotation.size_ratio; + let annotation_measure = + self.measure_styled(style, Some(annotation_size), &annotation.text); + let base_measure = self.measure_styled(style, None, base_text); + if base_measure.glyphs <= 4 && annotation_measure.glyphs <= 8 { + monolithic_rubies.push(range.clone()); + } + if annotation.text.is_empty() { continue; } - let annotation_size = style.font.size.get() * annotation.size_ratio; - let annotation_advance = - self.measure_styled_advance(style, Some(annotation_size), &annotation.text); - let base_advance = self.measure_styled_advance(style, None, base_text); + let annotation_advance = annotation_measure.advance; + let base_advance = base_measure.advance; ruby_annotation_widths.insert(*item_index, annotation_advance); let space = layout_unit_ceil(annotation_advance) - layout_unit_ceil(base_advance); if space <= 0.0 { @@ -262,7 +283,7 @@ impl ParleyInlineContext { // The base's justification inset: half the per-opportunity // share the base line's justification would leave at each // edge, on the layout grid at every step. - let plan = line_justify_plan(base_text, 0..base_text.len(), space, &[], &[]); + let plan = line_justify_plan(base_text, 0..base_text.len(), space, &[], &[], &[]); let count = plan.as_ref().map_or(0, |plan| plan.total); let inset_full = layout_unit_trunc(space / (f64::from(count) + 1.0)); let inset = layout_unit_trunc(inset_full / 2.0); @@ -306,7 +327,9 @@ impl ParleyInlineContext { } allowed }; - let overhang_left = overhang(prev_byte, true); + let natural_overhang_left = overhang(prev_byte, true); + let unhung = edits.unhung_starts.binary_search(item_index).is_ok(); + let overhang_left = if unhung { 0.0 } else { natural_overhang_left }; let overhang_right = overhang(next_byte, false); let author = match style.text_flow.letter_spacing { LengthPercentage::Length(px) => px.get(), @@ -325,11 +348,24 @@ impl ParleyInlineContext { if delta > 0.0 { ruby_spread_edits.push((last_cluster_start..range.end, author + delta as f32)); } + ruby_carriers.push(RubyCarrier { + item: *item_index, + range: last_cluster_start..range.end, + author, + hung: (space - natural_overhang_left - overhang_right) as f32, + unhung: (space - overhang_right) as f32, + start_overhang: natural_overhang_left as f32, + }); ruby_spreads.insert(*item_index, 0.0); ruby_spread_overhangs.insert(*item_index, overhang_left); ruby_spread_overhangs_right.insert(*item_index, overhang_right); - ruby_center_shifts - .insert(*item_index, layout_unit_trunc(space / 2.0) - overhang_left); + ruby_base_shifts.insert( + *item_index, + RubyBaseShift { + within: layout_unit_trunc(space / 2.0) - overhang_left, + at_edges: None, + }, + ); continue; } // Each opportunity's shares open after the boundary's left @@ -354,10 +390,25 @@ impl ParleyInlineContext { ruby_spread_edits .push((last_cluster_start..range.end, author + edge_carrier as f32)); } - let absorbed_left = inset - overhang_left; - if absorbed_left > 0.0 { - ruby_center_shifts.insert(*item_index, absorbed_left); - } + ruby_carriers.push(RubyCarrier { + item: *item_index, + range: last_cluster_start..range.end, + author, + hung: (inset_full - natural_overhang_left - overhang_right) as f32, + unhung: (inset_full - overhang_right) as f32, + start_overhang: natural_overhang_left as f32, + }); + // A justified line's edge pulls the base to it: the whole + // inset opens past the base at the line's start and before it + // at the line's end (both edges measured from the column's + // start, the start overhang back of the flow position). + ruby_base_shifts.insert( + *item_index, + RubyBaseShift { + within: inset - overhang_left, + at_edges: Some((-overhang_left, 2.0 * inset - overhang_left)), + }, + ); ruby_spreads.insert(*item_index, share); ruby_spread_overhangs.insert(*item_index, overhang_left); ruby_spread_overhangs_right.insert(*item_index, overhang_right); @@ -366,43 +417,57 @@ impl ParleyInlineContext { let mut fonts = self.fonts.borrow_mut(); // Computed before the builder takes the font borrow: the trim // gate resolves each trimmed character's font to check `halt`. - let inline_box_bytes: Vec = image_boxes + let inline_box_bytes: Vec = inline_boxes .iter() + .filter(|inline_box| inline_box.kind == InlineBoxKind::InFlow) .map(|inline_box| inline_box.index) .collect(); + // Every pair the paragraph trims while no break separates it; the + // edits say which a break did. let punctuation_trims = compute_cjk_punctuation_trims( &mut fonts, &self.catalog, &mut self.halt_feature_cache.borrow_mut(), &text, &runs, - suppressed_pair_trims, + &[], &inline_box_bytes, ); - let pair_trims: Vec<(usize, usize)> = punctuation_trims - .iter() - .map(|trim| (trim.left_byte, trim.right_byte)) - .collect(); + // Chromium's break opportunities, each text item read under the + // ICU rule set its content language and `line-break` select, with + // its `word-break`. + let opportunities = BreakOpportunities::new(&text, |offset| { + let at = runs.partition_point(|(range, _, _)| range.start <= offset); + let flow = runs + .get(at.saturating_sub(1)) + .map(|(_, style, _)| &style.text_flow); + BreakContext { + rule_set: flow.map_or(RuleSet::Normal, |flow| { + RuleSet::for_content( + flow.language.as_ref().map(|tag| tag.as_str()), + flow.line_break, + ) + }), + word_break: flow.map_or(rito_style_contract::WordBreak::Normal, |flow| { + flow.word_break + }), + anywhere: flow.is_some_and(|flow| { + flow.line_break == rito_style_contract::LineBreak::Anywhere + }), + collapses: flow.is_none_or(|flow| { + matches!( + flow.white_space_collapse, + rito_style_contract::WhiteSpaceCollapse::Collapse + | rito_style_contract::WhiteSpaceCollapse::PreserveBreaks + ) + }), + } + }); + let chromium_breaks = + move |context: parley::LineBreakContext| opportunities.before(context.index); let mut layouts = self.layouts.borrow_mut(); let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, &text, 1.0, true)); - // The pinned-browser baseline: Chromium's ASCII break tailoring plus - // its CJK-context treatment of ambiguous curly quotes. - if break_anywhere { - // `line-break: anywhere`: a soft wrap opportunity around every - // typographic character unit — kinsoku and pair tables are - // disregarded entirely (b50's afterword packs two more - // full-width characters per line than any kinsoku-aware rule - // set allows, breaking mid-ellipsis and before commas). - builder.set_line_break_override(&break_anywhere_override); - } else if chromium_tailoring { - if strict_kinsoku { - builder.set_line_break_override(&cjk_aware_chromium_break_override_strict); - } else { - builder.set_line_break_override(&cjk_aware_chromium_break_override); - } - } else if break_all { - builder.set_line_break_override(&break_all_box_dash_override); - } + builder.set_line_break_override(&chromium_breaks); // `text-indent` is the block container's own inherited property and // indents its first line whatever sits on it — a line holding only // an image included. Reading it off whichever text run happens to @@ -414,16 +479,34 @@ impl ParleyInlineContext { InlineItem::Text { style, .. } | InlineItem::Image { style, .. } | InlineItem::InlineBlock { style, .. } - | InlineItem::EmptyBox { style, .. } => *style, + | InlineItem::EmptyBox { style, .. } + | InlineItem::OpenTag { style } + | InlineItem::CloseTag { style } => *style, }) }) .and_then(|style_id| styles.inline.style(style_id).ok()) .map_or(0.0_f32, resolved_text_indent); + // Each combined upright item sets as one unit an em down the line, + // shaped apart from its neighbours as the browser lays it out in a + // box of its own (an explicitly-off `hwid`, inert, splits the run). + let mut combined: Vec<(std::ops::Range, f32)> = Vec::new(); for (range, style, item_index) in &runs { if range.is_empty() { continue; } - push_item_styles(&mut builder, &self.catalog, style, range.clone()); + push_item_styles(&mut builder, &self.catalog, style, &text, range.clone()); + if combines_upright(style) { + combined.push((range.clone(), combined_advance(style))); + builder.push( + StyleProperty::FontFeatures(parley::FontFeatures::List( + std::borrow::Cow::Owned(vec![parley::FontFeature::new( + parley::setting::Tag::new(b"hwid"), + 0, + )]), + )), + range.clone(), + ); + } // Parley merges adjacent resolved styles that compare equal, // which would fuse glyph runs across item boundaries whenever // neighbouring items differ only in properties Parley never @@ -471,6 +554,9 @@ impl ParleyInlineContext { ); } } + for range in monolithic_rubies { + builder.push_unbreakable(range); + } // A space takes the FIRST family of its stack in the browser // (every face covers U+0020), while parley merges a space into // the neighbouring script run: a space between a CJK glyph and @@ -510,123 +596,30 @@ impl ParleyInlineContext { } } - // Inline box advances: a span's horizontal padding and borders - // widen the gap at each box boundary. The gap rides as letter - // spacing on the character left of the boundary (the same - // mechanism as the punctuation trims); a box opening at the very - // start of the flow folds its lead into the first-line indent. - // Each edit is (range, box gap, author letter-spacing): standalone - // pushes apply author + gap; an edit landing on a trimmed - // character adds only the gap (the trim value already carries the - // author spacing). - let mut box_edits: Vec<(std::ops::Range, f32, f32)> = Vec::new(); - let mut leading_box_indent = 0.0_f32; - let mut item_box_sheds: std::collections::HashMap = - std::collections::HashMap::new(); - let mut forced_line_indents: std::collections::HashMap = - std::collections::HashMap::new(); - { - let box_side = - |value: &rito_style_contract::NonNegativeLengthPercentage| match value.value() { - LengthPercentage::Length(px) => px.get(), - _ => 0.0, - }; - let edge_width = |edge: &rito_style_contract::BorderEdge| { - use rito_style_contract::BorderStyle; - if matches!(edge.style, BorderStyle::None | BorderStyle::Hidden) { - 0.0 - } else { - edge.resolved_width.get() - } - }; - let author = |style: &InlineFormattingStyle| match style.text_flow.letter_spacing { - LengthPercentage::Length(px) => px.get(), - _ => 0.0, - }; - // Inline horizontal margins displace the inline box - // exactly like padding/border gaps, but stay OUTSIDE the - // painted box (the pen grows the box by paint padding only). - // Percentages resolve against the containing block's inline - // size; vertical inline margins have no effect in CSS. - let margin_side = |value: &rito_style_contract::LengthPercentageOrAuto| match value { - rito_style_contract::LengthPercentageOrAuto::Auto => 0.0_f32, - rito_style_contract::LengthPercentageOrAuto::Value(inner) => match inner { - LengthPercentage::Length(px) => px.get(), - LengthPercentage::Percentage(pct) => space - .margin_basis() - .map_or(0.0, |basis| pct.ratio() * basis as f32), - _ => 0.0, - }, - }; - for (index, (range, style, item_index)) in runs.iter().enumerate() { - if range.is_empty() { - continue; - } - let lead = box_side(&style.fragment.padding.left) - + edge_width(&style.fragment.border.left) - + margin_side(&style.fragment.margin.left); - let trail = box_side(&style.fragment.padding.right) - + edge_width(&style.fragment.border.right) - + margin_side(&style.fragment.margin.right); - if trail > 0.0 { - if let Some((last, _)) = text[range.clone()].char_indices().last() { - box_edits.push((range.start + last..range.end, trail, author(style))); - *item_box_sheds.entry(*item_index).or_insert(0.0) += f64::from(trail); - } - } - if lead > 0.0 { - if range.start == 0 { - leading_box_indent += lead; - } else if text.as_bytes().get(range.start - 1) == Some(&b'\n') { - // The span opens a forced-break line: the lead - // indents that line (a previous-char edit would - // widen the line ABOVE). Breaking does not see - // the reserved width — an indented long span may - // overfit vs Blink; b60-style badge lines hold - // one glyph and are exact. - *forced_line_indents.entry(range.start).or_insert(0.0) += f64::from(lead); - } else if let Some((prev_range, prev_style, prev_item)) = - runs.get(..index).and_then(|earlier| { - earlier - .iter() - .rev() - .find(|(earlier_range, ..)| !earlier_range.is_empty()) - }) - { - if let Some((last, _)) = text[prev_range.clone()].char_indices().last() { - box_edits.push(( - prev_range.start + last..prev_range.end, - lead, - author(prev_style), - )); - *item_box_sheds.entry(*prev_item).or_insert(0.0) += f64::from(lead); - } + let plan = SpacingPlan::new( + punctuation_trims, + ruby_spread_edits, + ruby_carriers, + runs.iter() + .map(|(range, style, _)| { + let author = match style.text_flow.letter_spacing { + LengthPercentage::Length(px) => px.get(), + _ => 0.0, + }; + PlannedRun { + range: range.clone(), + author, + end_trim: author - 0.5 * shaping_font_size(style.font.size.get()), } - } - } - } - // Coincident box edits sum: one character can carry BOTH its own - // box's trailing gap and the next box's leading gap (b74's title - // cards — four adjacent bordered spans). Pushed separately they - // land on the same builder range and the later LetterSpacing - // OVERWRITES the earlier, silently dropping one gap (every - // non-final card lost its 4px trail). The author spacing on both - // edits comes from the same character's style, so merging keeps - // it single-counted. - { - let mut coalesced: Vec<(std::ops::Range, f32, f32)> = Vec::new(); - for (range, gap, author) in box_edits.drain(..) { - if let Some(existing) = coalesced.iter_mut().find(|(seen, ..)| *seen == range) { - existing.1 += gap; - } else { - coalesced.push((range, gap, author)); - } - } - box_edits = coalesced; - } - let first_line_indent = first_line_indent + leading_box_indent; - let opener_halt_trims: Vec<(std::ops::Range, f64)> = punctuation_trims - .iter() + }) + .collect(), + ); + let pair_trims: Vec<(usize, usize)> = plan + .applied_pair_trims(edits) + .map(|trim| (trim.left_byte, trim.right_byte)) + .collect(); + let opener_halt_trims: Vec<(std::ops::Range, f64)> = plan + .applied_pair_trims(edits) .filter_map(|trim| match trim.edit { PunctuationTrimEdit::OpenerHalt(half) => { Some((trim.edit_range.clone(), f64::from(half))) @@ -634,52 +627,35 @@ impl ParleyInlineContext { PunctuationTrimEdit::LetterSpacing(_) => None, }) .collect(); - for trim in punctuation_trims { - let range = trim.edit_range; - let spacing = match trim.edit { - PunctuationTrimEdit::OpenerHalt(_) => { + for push in plan.pushes(&text, edits) { + match push { + SpacingPush::LetterSpacing(range, spacing) => { + builder.push(StyleProperty::LetterSpacing(spacing), range); + } + SpacingPush::Halt(range) => { + // An opener shaped top to bottom takes its vertical + // half width. + let vertical = runs + .iter() + .find(|(run, ..)| run.contains(&range.start)) + .zip(text.get(range.start..).and_then(|rest| rest.chars().next())) + .is_some_and(|((_, style, _), character)| { + shapes_top_to_bottom(style, character) + }); builder.push( StyleProperty::FontFeatures(parley::FontFeatures::List( std::borrow::Cow::Owned(vec![parley::FontFeature::new( - parley::setting::Tag::new(b"halt"), + parley::setting::Tag::new(halt_tag(vertical)), 1, )]), )), range, - ); - continue; - } - PunctuationTrimEdit::LetterSpacing(spacing) => spacing, - }; - // A box gap on the same character composes with the trim (the - // trim value already carries the author spacing). - let boxed = box_edits - .iter() - .position(|(edit_range, ..)| *edit_range == range); - let spacing = match boxed { - Some(found) => { - let (_, gap, _) = box_edits.remove(found); - spacing + gap + ) } - None => spacing, - }; - builder.push(StyleProperty::LetterSpacing(spacing), range); - } - for (range, gap, author) in box_edits { - builder.push(StyleProperty::LetterSpacing(author + gap), range); - } - for (range, spacing) in &ruby_spread_edits { - builder.push(StyleProperty::LetterSpacing(*spacing), range.clone()); - } - // A split spread segment's widening, decided by the outer layout - // loop once line breaks are known (the segment carries its - // allocated annotation words and spreads to their advance). - for (range, spacing) in split_spread_edits { - builder.push(StyleProperty::LetterSpacing(*spacing), range.clone()); + } } - push_line_end_trims(&mut builder, &text, &runs, end_trims); - for image_box in image_boxes { - builder.push_inline_box(image_box); + for inline_box in inline_boxes { + builder.push_inline_box(inline_box); } if cancel.is_cancelled() { return Err(LayoutError::Cancelled); @@ -697,7 +673,9 @@ impl ParleyInlineContext { InlineItem::Text { style, .. } | InlineItem::Image { style, .. } | InlineItem::InlineBlock { style, .. } - | InlineItem::EmptyBox { style, .. } => *style, + | InlineItem::EmptyBox { style, .. } + | InlineItem::OpenTag { style } + | InlineItem::CloseTag { style } => *style, }) }) .map(|style_id| { @@ -710,6 +688,9 @@ impl ParleyInlineContext { .transpose()? .unwrap_or(parley::Alignment::Start); let (mut layout, spacing_edits) = builder.build(&text); + for (range, advance) in combined { + layout.combine_upright(range, advance); + } // Parley's own first-line indent: a start-edge margin on the // indented line. Reserving the space with an inline box instead // would invent a break opportunity that CSS does not have, and an @@ -731,16 +712,16 @@ impl ParleyInlineContext { empty_box_struts, pair_trims, opener_halt_trims, - item_box_sheds, - forced_line_indents, ruby_spreads, ruby_spread_overhangs, ruby_spread_overhangs_right, ruby_annotation_widths, - ruby_center_shifts, + ruby_base_shifts, cluster_steps: std::rc::Rc::default(), line_end_trims: std::rc::Rc::default(), kept_breaks: std::rc::Rc::default(), + spacing_plan: std::rc::Rc::new(plan), + kept_decisions: std::rc::Rc::default(), reads_space: false, }) } @@ -798,6 +779,10 @@ impl<'a> SpacingBuilder<'a> { self.inner.push_inline_box(inline_box); } + pub(crate) fn push_unbreakable(&mut self, range: std::ops::Range) { + self.inner.push_unbreakable(range); + } + pub(crate) fn set_line_break_override(&mut self, overrides: &'a parley::LineBreakOverrideFn) { self.inner.set_line_break_override(Some(overrides)); } @@ -810,6 +795,43 @@ impl<'a> SpacingBuilder<'a> { /// One paragraph's built Parley layout plus the metadata the fragment /// assembly needs. +/// Where a wide-annotation ruby's base glyphs paint inside its column, +/// past the flow position of the base's first glyph (Blink's ruby base +/// insets, `ApplyRubyAlign`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RubyBaseShift { + /// Away from a justified line's edges. + pub(crate) within: f64, + /// At a justified paragraph's line start and a justified line's end, + /// where `space-around` aligns the base to the line's edge + /// (css-ruby line-edge alignment); `None` for `ruby-align: center`, + /// which keeps the base centered there too. + pub(crate) at_edges: Option<(f64, f64)>, +} + +impl RubyBaseShift { + /// The shift for a base spanning `base` on a line whose in-flow content + /// starts at `line_start` and, on a justified line, ends at `line_end` + /// (`None` off a justified paragraph, or where an atom takes the edge). + pub(crate) fn on_line( + &self, + base: &std::ops::Range, + line_start: Option, + line_end: Option, + ) -> f64 { + let Some((at_start, at_end)) = self.at_edges else { + return self.within; + }; + match (line_start == Some(base.start), line_end == Some(base.end)) { + (true, false) => at_start, + (false, true) => at_end, + // A ruby alone on a justified line spreads over the whole line, + // which is not modelled. + _ => self.within, + } + } +} + #[derive(Clone)] pub(crate) struct ParagraphLayout { /// Whether the build read the space it was built in (an image or an @@ -827,6 +849,12 @@ pub(crate) struct ParagraphLayout { /// The lines this build was last broken into, shared like /// `cluster_steps` (see [`KeptBreaks`]). pub(crate) kept_breaks: std::rc::Rc, + /// The spacing the build pushed on top of its runs' styles, apart from + /// the edits it was built with (see [`SpacingPlan`]). + pub(crate) spacing_plan: std::rc::Rc, + /// The line edges decided on this build, by the space they were + /// decided in (see [`KeptDecisions`]). + pub(crate) kept_decisions: std::rc::Rc, pub(crate) layout: parley::Layout<[u8; 4]>, pub(crate) text: String, pub(crate) alignment: parley::Alignment, @@ -852,20 +880,6 @@ pub(crate) struct ParagraphLayout { /// width each removed — the painter draws the untrimmed glyph shifted /// left by that amount so its ink lands where the halt variant sits. pub(crate) opener_halt_trims: Vec<(std::ops::Range, f64)>, - /// Per item index: advance the item's LAST cluster gained from inline - /// box gaps (its own trailing padding/border, plus the leading - /// padding/border of a box opening right after it). Emitted fragment - /// widths shed it so the run rect stays the ink advance the painter - /// grows the inline box from. - pub(crate) item_box_sheds: std::collections::HashMap, - /// Per forced-break line start (flow-text byte): the box lead - /// (margin, padding, border) of a span opening that line. A lead - /// riding the previous character's letter spacing would widen the - /// PREVIOUS line across a `
`; Blink indents the span's own line - /// (u3000/inline-margin oracle: margin box at x=30, padding glyph at - /// +30, both on the span's line), so the line loop shifts the whole - /// line instead. - pub(crate) forced_line_indents: std::collections::HashMap, /// Per item index: the `ruby-align: space-around` interior gap a /// wide annotation opens between its base's clusters. The gap is /// already injected as letter spacing on every base cluster but the @@ -886,9 +900,9 @@ pub(crate) struct ParagraphLayout { /// annotation and widens to at least this advance, which is what the /// split-fit rewind checks. pub(crate) ruby_annotation_widths: std::collections::HashMap, - /// Per item: the paint-side right shift centering a packed base - /// under its wide `ruby-align: center` annotation. - pub(crate) ruby_center_shifts: std::collections::HashMap, + /// Per wide-annotation item: where its base glyphs paint inside the + /// column. + pub(crate) ruby_base_shifts: std::collections::HashMap, /// Laid-out inline-block atoms by item index: the mini paragraph's /// baseline (its LAST line's, from the box top) and its fragment, /// emitted at the inline box's position during line assembly. @@ -912,7 +926,7 @@ pub(crate) struct ParagraphLayout { } /// Key of one line-metric entry: (family key, size bits, line shape). -pub(crate) type LineMetricKey = (u32, u64, LineProbe); +pub(crate) type LineMetricKey = (u32, u64, LineBaseline, LineProbe); /// The browser shapes at the computed font size truncated toward zero /// onto the 1/100 px grid, and the product is an F32 MULTIPLY — the @@ -935,8 +949,22 @@ pub(crate) fn push_item_styles( builder: &mut SpacingBuilder<'_>, catalog: &FontCatalog, style: &InlineFormattingStyle, + text: &str, range: std::ops::Range, ) { + // A vertical line's upright characters shape top to bottom, as Blink + // shapes a vertical line's upright runs; the rest shape as horizontal + // text, laid sideways, and so does a combined upright unit's. + if style.bidi.writing_mode != WritingMode::HorizontalTopToBottom && !combines_upright(style) { + if let Some(item_text) = text.get(range.clone()) { + for upright in upright_ranges(style.bidi.text_orientation, item_text) { + builder.push( + StyleProperty::VerticalUpright(true), + range.start + upright.start..range.start + upright.end, + ); + } + } + } let stack = catalog.resolve(style); builder.push( StyleProperty::FontFamily(parley::FontFamily::Source(Cow::Owned(stack.source.clone()))), @@ -1077,6 +1105,10 @@ pub(crate) fn install_universal_fallbacks( } } +/// The id bit of an inline box that is a box's start or end size rather +/// than an atom (whose id is its item index). +pub(crate) const TAG_BOX_ID: u64 = 1 << 40; + /// A plain-text paragraph style: the given font stack and size, `normal` /// line height and weight, no decoration, spacing, or transforms, and an /// optional first-line indent. This is the style of an undecorated body @@ -1166,12 +1198,15 @@ pub fn plain_paragraph_style( each_line: false, }, ruby_align: RubyAlign::SpaceAround, + text_emphasis: None, language: None, }, bidi: InlineBidi { direction: Direction::LeftToRight, unicode_bidi: UnicodeBidi::Normal, writing_mode: WritingMode::HorizontalTopToBottom, + text_orientation: rito_style_contract::TextOrientation::Mixed, + text_combine_upright: rito_style_contract::TextCombineUpright::None, }, fragment: InlineFragmentStyle { margin: sides(LengthPercentageOrAuto::Value(zero_length)), diff --git a/crates/rito-inline/src/punctuation.rs b/crates/rito-inline/src/punctuation.rs index 96f4f0063..a1f84d52f 100644 --- a/crates/rito-inline/src/punctuation.rs +++ b/crates/rito-inline/src/punctuation.rs @@ -10,15 +10,32 @@ pub(crate) enum TrimmedGlyph { Right, } +/// A character's static Han kerning class (Blink's +/// `unicode::GetHanKerningCharType`, css-text-4's text-spacing classes) +/// as the pinned CJK faces resolve it. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PunctuationClass { + /// Fullwidth opening punctuation: `Ps` in the CJK Symbols block or + /// East Asian Fullwidth, and the curly opening quotes. Open, + /// Fullwidth closing punctuation (`Pe` likewise, the curly closing + /// quotes) and the dots and colons, whose blank half sits on the right + /// in the pinned faces' Simplified Chinese forms. CloseOrStop, + /// The ideographic space and the middle dots: they make their + /// neighbours trim but never trim themselves. Middle, + /// Any other opening punctuation (`(`, `[`, halfwidth brackets): a + /// fullwidth opener after it trims. + OpenNarrow, + /// Any other closing punctuation: a fullwidth closer before it trims. + CloseNarrow, Other, } pub(crate) fn fullwidth_punctuation_class(character: char) -> PunctuationClass { + use icu_properties::props::{EastAsianWidth, GeneralCategory}; + use icu_properties::CodePointMapData; match character { // The curly quotes are ambiguous-width, but in the CJK faces this // engine shapes with they are fullwidth, and the pinned Chromium @@ -27,20 +44,28 @@ pub(crate) fn fullwidth_punctuation_class(character: char) -> PunctuationClass { // 8+16, `「“` costs 16+8, and none of them trim against an // ideograph. Blink types them kOpenQuote/kCloseQuote, which its // Han kerning treats as kOpen/kClose. - '「' | '『' | '(' | '【' | '〔' | '《' | '〈' | '〖' | '〘' | '〚' | '{' | '[' - | '⦅' | '‘' | '“' => PunctuationClass::Open, - '」' | '』' | ')' | '】' | '〕' | '》' | '〉' | '〗' | '〙' | '〛' | '}' | ']' - | '⦆' | '。' | '、' | ',' | '.' | ':' | ';' | '’' | '”' => { + '‘' | '“' => PunctuationClass::Open, + '’' | '”' | '。' | '、' | ',' | '.' | ':' | ';' => { PunctuationClass::CloseOrStop } // The ideographic space is fullwidth-punctuation CONTEXT: an // opener after it halts ( 「 = 16+8) and a close/stop before it // halts (』  = 8+16), while the space itself never trims — //    and  、 stay full (measured 6-pair matrix, 2026-08-08). - // The Middle class carries exactly that trigger-but-never- - // trimmed behaviour. - '・' | ' ' => PunctuationClass::Middle, - _ => PunctuationClass::Other, + // The middle dots are the same class (measured: ·「 = 5.33+8). + ' ' | '・' | '·' | '‧' => PunctuationClass::Middle, + _ => { + let fullwidth = ('\u{3000}'..='\u{303F}').contains(&character) + || CodePointMapData::::new().get(character) + == EastAsianWidth::Fullwidth; + match CodePointMapData::::new().get(character) { + GeneralCategory::OpenPunctuation if fullwidth => PunctuationClass::Open, + GeneralCategory::OpenPunctuation => PunctuationClass::OpenNarrow, + GeneralCategory::ClosePunctuation if fullwidth => PunctuationClass::CloseOrStop, + GeneralCategory::ClosePunctuation => PunctuationClass::CloseNarrow, + _ => PunctuationClass::Other, + } + } } } @@ -49,20 +74,21 @@ pub(crate) fn fullwidth_punctuation_class(character: char) -> PunctuationClass { /// Chromium ships `text-spacing-trim: normal` on by default for CJK text /// (Blink "Han kerning"): where two fullwidth punctuation glyphs meet, the /// blank half at the boundary collapses so the pair advances 1.5em instead -/// of 2em. Characterized against pinned Chromium (scratchpad trim probes, -/// 2026-07-23, 54-pair matrix): an opening bracket trims its blank left -/// half after any fullwidth punctuation; a close/stop/colon trims its -/// blank right half before any fullwidth punctuation; nothing trims -/// against an ideograph or at a line edge, `!?・` never trim themselves, -/// the trim applies with or without justification, and it crosses inline -/// element boundaries. +/// of 2em. Blink's `HanKerning::ShouldKern` trims an opener after an +/// opener, a middle dot or space, a closer or a narrow opener +/// (measured: `、〝` = 16+8, `(「` = 5.33+8), and `ShouldKernLast` trims a +/// closer before a closer, a middle dot or space or a narrow closer +/// (`〟、` = 8+16, `」)` = 8+5.33); nothing trims against an ideograph or +/// at a line edge, `!?・` never trim themselves, the trim applies with or +/// without justification, and it crosses inline element boundaries. pub(crate) fn cjk_punctuation_trim(left: char, right: char) -> Option { + use PunctuationClass::*; let left_class = fullwidth_punctuation_class(left); let right_class = fullwidth_punctuation_class(right); - if right_class == PunctuationClass::Open && left_class != PunctuationClass::Other { + if right_class == Open && matches!(left_class, Open | Middle | CloseOrStop | OpenNarrow) { return Some(TrimmedGlyph::Right); } - if left_class == PunctuationClass::CloseOrStop && right_class != PunctuationClass::Other { + if left_class == CloseOrStop && matches!(right_class, CloseOrStop | Middle | CloseNarrow) { return Some(TrimmedGlyph::Left); } None @@ -100,13 +126,14 @@ pub(crate) enum PunctuationTrimEdit { /// The boundary trims as edits, computed before the shaping builder /// exists so the trim gate can consult the font collection. Blink's Han /// kerning only adjusts glyphs whose resolved font carries the OpenType -/// `halt` feature (measured: a book-embedded face without it keeps `。」` -/// at two full advances while the pinned SourceHan trims), so each -/// trimmed character resolves its font first. +/// `halt` feature, `vhal` for a glyph shaped top to bottom (measured: a +/// book-embedded face without it keeps `。」` at two full advances while +/// the pinned SourceHan trims), so each trimmed character resolves its +/// font first. pub(crate) fn compute_cjk_punctuation_trims( fonts: &mut FontContext, catalog: &FontCatalog, - halt_cache: &mut rustc_hash::FxHashMap<(u64, u32), bool>, + halt_cache: &mut rustc_hash::FxHashMap<(u64, u32, bool), bool>, text: &str, runs: &[(std::ops::Range, &InlineFormattingStyle, usize)], suppressed_pairs: &[usize], @@ -213,17 +240,20 @@ pub(crate) fn compute_cjk_punctuation_trims( } /// Whether the font the style resolves for `character` carries the -/// OpenType `halt` feature. Resolution mirrors CSS font matching: the -/// first stack family whose matched face covers the character wins, and -/// the registered families (the engine's installed fallback order) stand -/// in for script fallback. An unresolvable character trims nothing. +/// OpenType `halt` feature, or `vhal` where the character shapes top to +/// bottom (Blink's `HanKerning::FontData`), and whether that feature moves +/// the glyph. Resolution mirrors CSS font matching: the first stack family +/// whose matched face covers the character wins, and the registered +/// families (the engine's installed fallback order) stand in for script +/// fallback. An unresolvable character trims nothing. pub(crate) fn resolved_font_halt( fonts: &mut FontContext, catalog: &FontCatalog, - halt_cache: &mut rustc_hash::FxHashMap<(u64, u32), bool>, + halt_cache: &mut rustc_hash::FxHashMap<(u64, u32, bool), bool>, style: &InlineFormattingStyle, character: char, ) -> Option { + let vertical = shapes_top_to_bottom(style, character); use parley::fontique::{FontStyle, FontWeight, FontWidth, SourceKind}; use skrifa::MetadataProvider as _; let weight = FontWeight::new(style.font.weight.get()); @@ -245,14 +275,14 @@ pub(crate) fn resolved_font_halt( if font_ref.charmap().map(character).is_none() { continue; } - let key = (blob.id(), font.index()); + let key = (blob.id(), font.index(), vertical); let has_halt = *halt_cache .entry(key) - .or_insert_with(|| font_ref_has_halt(&font_ref)); + .or_insert_with(|| font_ref_has_halt(&font_ref, vertical)); if !has_halt { return None; } - return Some(font_halt_covers(&font_ref, character)); + return Some(font_halt_covers(&font_ref, character, vertical)); } None } @@ -267,13 +297,24 @@ pub(crate) fn cluster_font_has_halt( catalog: &FontCatalog, cluster: &parley::layout::Cluster<'_, [u8; 4]>, ) -> bool { - catalog.has_halt(cluster.run().font()) + catalog.has_halt(cluster.run().font(), cluster.run().is_vertical_upright()) } -/// Whether the face declares the OpenType `halt` feature in GSUB or GPOS. -pub(crate) fn font_ref_has_halt(font: &skrifa::FontRef) -> bool { +/// The OpenType feature that sets a CJK punctuation glyph's alternate half +/// width: `halt`, or `vhal` for a glyph shaped top to bottom. +pub(crate) fn halt_tag(vertical: bool) -> &'static [u8; 4] { + if vertical { + b"vhal" + } else { + b"halt" + } +} + +/// Whether the face declares its alternate half widths (see [`halt_tag`]) +/// in GSUB or GPOS. +pub(crate) fn font_ref_has_halt(font: &skrifa::FontRef, vertical: bool) -> bool { use skrifa::raw::TableProvider as _; - let tag = skrifa::raw::types::Tag::new(b"halt"); + let tag = skrifa::raw::types::Tag::new(halt_tag(vertical)); let gsub = font.gsub().ok().and_then(|table| table.feature_list().ok()); let gpos = font.gpos().ok().and_then(|table| table.feature_list().ok()); gsub.is_some_and(|list| { @@ -288,19 +329,26 @@ pub(crate) fn font_ref_has_halt(font: &skrifa::FontRef) -> bool { } /// Whether the face's `halt` feature actually REPOSITIONS `character` — -/// its GPOS `halt` lookups cover the mapped glyph. A face may declare -/// `halt` for a subset only (b12's BuMing covers 51 glyphs, the corner -/// bracket excluded): shaping such a glyph with the feature is a no-op, -/// and the browser SYNTHESIZES the half-width trim instead. Parse -/// failures report `true` so the shaped path stays the default. -pub(crate) fn font_halt_covers(font: &skrifa::FontRef, character: char) -> bool { +/// its GPOS `halt` lookups cover the mapped glyph, or for a glyph shaped +/// top to bottom its `vhal` lookups cover the vertical form `vert` puts +/// there. A face may declare `halt` for a subset only (b12's BuMing covers +/// 51 glyphs, the corner bracket excluded): shaping such a glyph with the +/// feature is a no-op, and the browser SYNTHESIZES the half-width trim +/// instead. Parse failures report `true` so the shaped path stays the +/// default. +pub(crate) fn font_halt_covers(font: &skrifa::FontRef, character: char, vertical: bool) -> bool { use skrifa::raw::tables::gpos::PositionLookup; use skrifa::raw::TableProvider as _; use skrifa::MetadataProvider as _; let Some(glyph) = font.charmap().map(character) else { return true; }; - let tag = skrifa::raw::types::Tag::new(b"halt"); + let glyph = if vertical { + vertical_form(font, glyph) + } else { + glyph + }; + let tag = skrifa::raw::types::Tag::new(halt_tag(vertical)); let Ok(gpos) = font.gpos() else { return true; }; @@ -347,3 +395,56 @@ pub(crate) fn font_halt_covers(font: &skrifa::FontRef, character: char) -> bool } false } + +/// The glyph the face's `vert` feature substitutes for `glyph` — its form +/// in a vertical line — or `glyph` itself when no single substitution of +/// the feature covers it. +pub(crate) fn vertical_form(font: &skrifa::FontRef, glyph: skrifa::GlyphId) -> skrifa::GlyphId { + use skrifa::raw::tables::gsub::{SingleSubst, SubstitutionSubtables}; + use skrifa::raw::TableProvider as _; + let tag = skrifa::raw::types::Tag::new(b"vert"); + let Ok(gsub) = font.gsub() else { + return glyph; + }; + let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { + return glyph; + }; + for record in features.feature_records() { + if record.feature_tag() != tag { + continue; + } + let Ok(feature) = record.feature(features.offset_data()) else { + continue; + }; + for index in feature.lookup_list_indices() { + let Ok(lookup) = lookups.lookups().get(index.get() as usize) else { + continue; + }; + let Ok(SubstitutionSubtables::Single(subtables)) = lookup.subtables() else { + continue; + }; + for subtable in subtables.iter().flatten() { + let substitute = match &subtable { + SingleSubst::Format1(table) => table.coverage().ok().and_then(|coverage| { + coverage.get(glyph).map(|_| { + let id = u16::try_from(glyph.to_u32()).unwrap_or_default(); + skrifa::GlyphId::from(id.wrapping_add_signed(table.delta_glyph_id())) + }) + }), + SingleSubst::Format2(table) => table.coverage().ok().and_then(|coverage| { + coverage.get(glyph).and_then(|index| { + table + .substitute_glyph_ids() + .get(index as usize) + .map(|id| skrifa::GlyphId::from(id.get())) + }) + }), + }; + if let Some(substitute) = substitute { + return substitute; + } + } + } + } + glyph +} diff --git a/crates/rito-inline/src/segmented_lru.rs b/crates/rito-inline/src/segmented_lru.rs index 31060fad4..4ef7f1a3d 100644 --- a/crates/rito-inline/src/segmented_lru.rs +++ b/crates/rito-inline/src/segmented_lru.rs @@ -109,6 +109,12 @@ impl SegmentedLru { } } + /// How many values are kept. + #[cfg(test)] + pub(crate) fn count(&self) -> usize { + self.entries.len() + } + pub(crate) fn clear(&mut self) { self.entries.clear(); self.probation.clear(); diff --git a/crates/rito-inline/src/shaped.rs b/crates/rito-inline/src/shaped.rs index ad6236b7d..73b012fa7 100644 --- a/crates/rito-inline/src/shaped.rs +++ b/crates/rito-inline/src/shaped.rs @@ -109,18 +109,21 @@ pub(crate) struct ShapeKey { suppressed_pair_trims: Vec, /// Range start, range end and spacing bits of each split spread. split_spreads: Vec<(usize, usize, u32)>, + unhung_starts: Vec, /// The space, when the shaping read it. space: Option<[Option; 3]>, } /// What the line loop has decided so far and builds the paragraph again -/// with: line-end trims, pair trims a break separated, and the spacing a -/// ruby base split across lines takes. -#[derive(Clone, Copy)] +/// with: line-end trims, pair trims a break separated, the spacing a ruby +/// base split across lines takes, and the rubies opening a line, which +/// overhang nothing before them (sorted item indices). +#[derive(Clone, Copy, Default)] pub(crate) struct ParagraphEdits<'a> { pub(crate) end_trims: &'a [usize], pub(crate) suppressed_pair_trims: &'a [usize], pub(crate) split_spreads: &'a [(std::ops::Range, f32)], + pub(crate) unhung_starts: &'a [usize], } impl ShapeKey { @@ -135,6 +138,7 @@ impl ShapeKey { .iter() .map(|(range, spacing)| (range.start, range.end, spacing.to_bits())) .collect(), + unhung_starts: edits.unhung_starts.to_vec(), space: None, } } @@ -206,6 +210,12 @@ impl ShapedParagraphs { self.kept.clear(); } + /// How many builds are kept. + #[cfg(test)] + pub(crate) fn kept_count(&self) -> usize { + self.kept.count() + } + fn touch(&mut self, key: &ShapeKey, space: &BuildSpace) -> Option { let entry = self.kept.get(key)?; Some(KeptParagraph { diff --git a/crates/rito-inline/src/spacing_plan.rs b/crates/rito-inline/src/spacing_plan.rs new file mode 100644 index 000000000..146e85783 --- /dev/null +++ b/crates/rito-inline/src/spacing_plan.rs @@ -0,0 +1,250 @@ +//! The letter spacing and `halt` features a paragraph's build pushes on +//! top of its runs' own styles, held apart from the edits the line loop +//! decides: the build emits its pushes from here, and the line loop reads +//! here what spacing any byte would carry under any edits without +//! building the paragraph again. +//! +//! The pushes layer as the builder takes them, a later push over the same +//! bytes replacing an earlier one: each run's author spacing (pushed with +//! its styles), the pair trims a break has not separated, the ruby +//! spreads, the split spreads, and last the line-end trims. A box's start +//! and end sizes are not spacing: they are inline boxes of their own. + +use std::ops::Range; + +use crate::{shaped::ParagraphEdits, PunctuationTrim, PunctuationTrimEdit}; + +pub(crate) struct SpacingPlan { + /// Every pair trim the paragraph takes while no break separates it, in + /// text order. + pair_trims: Vec, + /// Each pair trim by the character its edit applies to. + pair_by_edit: rustc_hash::FxHashMap, + ruby_spreads: Vec<(Range, f32)>, + /// Each spread ruby's edge carrier, by item index. + ruby_carriers: Vec, + runs: Vec, +} + +/// The cluster that carries a spread ruby's column past its base glyphs: +/// its last cluster, spaced by the column's width less its overhangs. +pub(crate) struct RubyCarrier { + /// The ruby's item index. + pub(crate) item: usize, + /// The last cluster's bytes. + pub(crate) range: Range, + /// The run's own letter spacing. + pub(crate) author: f32, + /// What the column carries past its glyphs when it overhangs both + /// sides (no spacing pushed when not positive). + pub(crate) hung: f32, + /// What it carries when it opens a line and overhangs nothing before + /// it. + pub(crate) unhung: f32, + /// What it overhangs the text before it by, when that text is on its + /// line. + pub(crate) start_overhang: f32, +} + +/// A text run's own letter spacing and the spacing a line-end trim gives +/// one of its characters. +pub(crate) struct PlannedRun { + pub(crate) range: Range, + pub(crate) author: f32, + pub(crate) end_trim: f32, +} + +/// One push the build makes on top of the runs' styles. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum SpacingPush { + LetterSpacing(Range, f32), + Halt(Range), +} + +impl SpacingPlan { + pub(crate) fn new( + pair_trims: Vec, + ruby_spreads: Vec<(Range, f32)>, + ruby_carriers: Vec, + runs: Vec, + ) -> Self { + let pair_by_edit = pair_trims + .iter() + .enumerate() + .map(|(index, trim)| (trim.edit_range.start, index)) + .collect(); + Self { + pair_trims, + pair_by_edit, + ruby_spreads, + ruby_carriers, + runs, + } + } + + /// The ruby item `item`'s carrier, when it spreads. + pub(crate) fn ruby_carrier(&self, item: usize) -> Option<&RubyCarrier> { + self.ruby_carriers + .iter() + .find(|carrier| carrier.item == item) + } + + /// The pair trims `edits` leaves applied: every one no break was found + /// to separate. + pub(crate) fn applied_pair_trims<'a>( + &'a self, + edits: ParagraphEdits<'a>, + ) -> impl Iterator + 'a { + self.pair_trims + .iter() + .filter(move |trim| !edits.suppressed_pair_trims.contains(&trim.left_byte)) + } + + /// The pushes a build under `edits` makes, in the order it makes them. + pub(crate) fn pushes(&self, text: &str, edits: ParagraphEdits<'_>) -> Vec { + let mut pushes = Vec::new(); + for trim in self.applied_pair_trims(edits) { + let range = trim.edit_range.clone(); + let spacing = match trim.edit { + PunctuationTrimEdit::OpenerHalt(_) => { + pushes.push(SpacingPush::Halt(range)); + continue; + } + PunctuationTrimEdit::LetterSpacing(spacing) => spacing, + }; + pushes.push(SpacingPush::LetterSpacing(range, spacing)); + } + for (range, spacing) in &self.ruby_spreads { + pushes.push(SpacingPush::LetterSpacing(range.clone(), *spacing)); + } + // A split spread segment's widening, decided by the line loop once + // line breaks are known (the segment carries its allocated + // annotation words and spreads to their advance). + for (range, spacing) in edits.split_spreads { + pushes.push(SpacingPush::LetterSpacing(range.clone(), *spacing)); + } + for &byte in edits.end_trims { + let (Some(character), Some(run)) = (text[byte..].chars().next(), self.run_at(byte)) + else { + continue; + }; + pushes.push(SpacingPush::LetterSpacing( + byte..byte + character.len_utf8(), + run.end_trim, + )); + } + pushes + } + + /// The letter spacing the character at `byte` carries in a build under + /// `edits` (its line-end trims and suppressed pairs sorted): the last + /// push over it, its run's author spacing below them. Found without + /// replaying the pushes: a pair trim and a line-end trim each cover one + /// character, found by its start. + pub(crate) fn letter_spacing_at( + &self, + text: &str, + byte: usize, + edits: ParagraphEdits<'_>, + ) -> f32 { + debug_assert!(edits.end_trims.is_sorted() && edits.suppressed_pair_trims.is_sorted()); + let run = self.run_at(byte); + if edits.end_trims.binary_search(&byte).is_ok() && text[byte..].chars().next().is_some() { + if let Some(run) = run { + return run.end_trim; + } + } + let latest = |spreads: &[(Range, f32)]| { + spreads + .iter() + .rev() + .find(|(range, _)| range.contains(&byte)) + .map(|(_, spacing)| *spacing) + }; + if let Some(spacing) = latest(edits.split_spreads) { + return spacing; + } + // A ruby's carrier keeps its start overhang unless the ruby opens a + // line, whatever the plan's own build decided. + if let Some(carrier) = self + .ruby_carriers + .iter() + .find(|carrier| carrier.range.contains(&byte)) + { + let carried = if edits.unhung_starts.binary_search(&carrier.item).is_ok() { + carrier.unhung + } else { + carrier.hung + }; + return carrier.author + carried.max(0.0); + } + if let Some(spacing) = latest(&self.ruby_spreads) { + return spacing; + } + self.pair_editing(byte) + .filter(|trim| !Self::suppressed(edits, trim)) + .and_then(|trim| match trim.edit { + PunctuationTrimEdit::LetterSpacing(spacing) => Some(spacing), + PunctuationTrimEdit::OpenerHalt(_) => None, + }) + .unwrap_or_else(|| run.map_or(0.0, |run| run.author)) + } + + /// The half width `halt` removes from the opener at `byte` in a build + /// under `edits`, when it is shaped with it. + pub(crate) fn halt_half_at(&self, byte: usize, edits: ParagraphEdits<'_>) -> Option { + self.pair_editing(byte) + .filter(|trim| !Self::suppressed(edits, trim)) + .and_then(|trim| match trim.edit { + PunctuationTrimEdit::OpenerHalt(half) => Some(half), + PunctuationTrimEdit::LetterSpacing(_) => None, + }) + } + + /// The pair trim applied under `edits` whose right character starts at + /// `right_byte`: the pair a break there separates. + pub(crate) fn applied_pair_ending_at( + &self, + right_byte: usize, + edits: ParagraphEdits<'_>, + ) -> Option<&PunctuationTrim> { + let at = self + .pair_trims + .partition_point(|trim| trim.right_byte < right_byte); + self.pair_trims + .get(at) + .filter(|trim| trim.right_byte == right_byte && !Self::suppressed(edits, trim)) + } + + /// Where the pair trim whose left character starts at `left_byte` + /// applies its edit. + pub(crate) fn pair_edit_start(&self, left_byte: usize) -> Option { + let at = self + .pair_trims + .partition_point(|trim| trim.left_byte < left_byte); + self.pair_trims + .get(at) + .filter(|trim| trim.left_byte == left_byte) + .map(|trim| trim.edit_range.start) + } + + /// The pair trim whose edit applies to the character at `byte`, applied + /// or not: each character is the edit of one pair at most. + fn pair_editing(&self, byte: usize) -> Option<&PunctuationTrim> { + self.pair_by_edit + .get(&byte) + .map(|&index| &self.pair_trims[index]) + } + + fn suppressed(edits: ParagraphEdits<'_>, trim: &PunctuationTrim) -> bool { + edits + .suppressed_pair_trims + .binary_search(&trim.left_byte) + .is_ok() + } + + fn run_at(&self, byte: usize) -> Option<&PlannedRun> { + let at = self.runs.partition_point(|run| run.range.end <= byte); + self.runs.get(at).filter(|run| run.range.contains(&byte)) + } +} diff --git a/crates/rito-inline/src/strut.rs b/crates/rito-inline/src/strut.rs index 20e83b8be..c2335f202 100644 --- a/crates/rito-inline/src/strut.rs +++ b/crates/rito-inline/src/strut.rs @@ -25,7 +25,9 @@ impl ParleyInlineContext { Some(InlineItem::Text { style, .. }) | Some(InlineItem::Image { style, .. }) | Some(InlineItem::InlineBlock { style, .. }) - | Some(InlineItem::EmptyBox { style, .. }) => *style, + | Some(InlineItem::EmptyBox { style, .. }) + | Some(InlineItem::OpenTag { style }) + | Some(InlineItem::CloseTag { style }) => *style, None => return Ok(None), }, }; @@ -54,8 +56,19 @@ impl ParleyInlineContext { size_override: Option, text: &str, ) -> f64 { + self.measure_styled(style, size_override, text).advance + } + + /// [`Self::measure_styled_advance`] with the number of glyphs the + /// string shapes into. + pub(crate) fn measure_styled( + &self, + style: &InlineFormattingStyle, + size_override: Option, + text: &str, + ) -> StyledMeasure { if text.is_empty() { - return 0.0; + return StyledMeasure::default(); } let mut sized; let style = match size_override @@ -71,17 +84,33 @@ impl ParleyInlineContext { let mut fonts = self.fonts.borrow_mut(); let mut layouts = self.layouts.borrow_mut(); let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, text, 1.0, true)); - push_item_styles(&mut builder, &self.catalog, style, 0..text.len()); + push_item_styles(&mut builder, &self.catalog, style, text, 0..text.len()); let (mut layout, _) = builder.build(text); layout.break_all_lines(None); - let advance = layout - .lines() - .next() - .map_or(0.0, |line| f64::from(line.metrics().advance)); - advance + let Some(line) = layout.lines().next() else { + return StyledMeasure::default(); + }; + let glyphs = line + .items() + .map(|item| match item { + PositionedLayoutItem::GlyphRun(glyph_run) => glyph_run.glyphs().count(), + PositionedLayoutItem::InlineBox(_) => 0, + }) + .sum(); + StyledMeasure { + advance: f64::from(line.metrics().advance), + glyphs, + } } } +/// A styled string's shaped advance and glyph count. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct StyledMeasure { + pub(crate) advance: f64, + pub(crate) glyphs: usize, +} + /// Quantizes a CSS length the way Blink's LayoutUnit stores it (1/64 px, /// nearest). A declared `line-height: 1.2em` at 16px is 19.2 in CSS /// arithmetic but 19.203125 in every Blink layout position; without this diff --git a/crates/rito-inline/src/tests/atoms_and_grid.rs b/crates/rito-inline/src/tests/atoms_and_grid.rs index 332c37be7..7c00259fb 100644 --- a/crates/rito-inline/src/tests/atoms_and_grid.rs +++ b/crates/rito-inline/src/tests/atoms_and_grid.rs @@ -70,7 +70,7 @@ fn an_atom_boundary_share_moves_the_atom_not_the_next_run() { fit_contain: false, object_fit: rito_style_contract::ObjectFit::Fill, viewport: None, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, baseline_shift_px: 0.0, }, InlineItem::Text { @@ -134,8 +134,7 @@ pub(super) fn jgap_image_layout_style() -> rito_style_contract::LayoutFormatting use rito_style_contract::{ AlignItems, BoxSizing, CellVerticalAlign, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, ListMarkerStyle, - MaximumHeight, MaximumSize, MinimumHeight, NonNegativeCssPx, Overflow, PageBreak, Position, - PreferredSize, + MaximumSize, MinimumSize, NonNegativeCssPx, Overflow, PageBreak, Position, PreferredSize, }; let auto = LengthPercentageOrAuto::Auto; let zero_padding = @@ -158,6 +157,9 @@ pub(super) fn jgap_image_layout_style() -> rito_style_contract::LayoutFormatting bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -166,8 +168,11 @@ pub(super) fn jgap_image_layout_style() -> rito_style_contract::LayoutFormatting width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -563,6 +568,7 @@ fn a_ruby_edge_is_a_shaping_boundary() { text: note.to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }; let merged = lay(&|style| vec![text_item("ウ,可", None, style)]); @@ -607,8 +613,8 @@ fn a_top_aligned_image_in_a_super_chain_overflows_the_line_top() { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleTable, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, - PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, + PreferredSize, }; let source_han = std::fs::read(concat!( env!("CARGO_MANIFEST_DIR"), @@ -659,6 +665,9 @@ fn a_top_aligned_image_in_a_super_chain_overflows_the_line_top() { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -667,8 +676,11 @@ fn a_top_aligned_image_in_a_super_chain_overflows_the_line_top() { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -707,7 +719,7 @@ fn a_top_aligned_image_in_a_super_chain_overflows_the_line_top() { fit_contain: false, viewport: None, baseline_shift_px: shift_px, - align_top: true, + align: rito_fragment::AtomAlign::Top, object_fit: rito_style_contract::ObjectFit::Fill, }, InlineItem::Text { @@ -774,8 +786,8 @@ fn an_inline_image_separates_a_punctuation_pair() { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleTable, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, - PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, + PreferredSize, }; let source_han = std::fs::read(concat!( env!("CARGO_MANIFEST_DIR"), @@ -826,6 +838,9 @@ fn an_inline_image_separates_a_punctuation_pair() { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -834,8 +849,11 @@ fn an_inline_image_separates_a_punctuation_pair() { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -874,7 +892,7 @@ fn an_inline_image_separates_a_punctuation_pair() { fit_contain: false, viewport: None, baseline_shift_px: 0.0, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, object_fit: rito_style_contract::ObjectFit::Fill, }); } @@ -977,6 +995,7 @@ fn the_b20_ruby_line_pitch_matches_truth() { text: "Shouichi".to_owned(), size_ratio: 0.7, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }, InlineItem::Text { @@ -1087,6 +1106,7 @@ fn the_b20_ruby_line_pitch_matches_truth() { text: "Shouko".to_owned(), size_ratio: 0.7, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }, InlineItem::Text { @@ -1185,6 +1205,7 @@ fn the_b20_double_ruby_paragraph_interior_pitch_matches_truth() { text: ann.to_owned(), size_ratio: 0.7, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }; let text = |t: &str| InlineItem::Text { @@ -1260,3 +1281,93 @@ fn the_b20_double_ruby_paragraph_interior_pitch_matches_truth() { "the interior ruby line's char pitch must be 27 like the real chapter, got {pitch}" ); } + +/// Down a vertical column an image sizes in physical terms: the page clamp +/// holds its height to the column's length (measured on a 318x2048 +/// chapter plate: Chromium lays it 132x850 on an 850px column). +#[test] +fn a_vertical_line_clamps_an_image_to_the_columns_length() { + let context = ParleyInlineContext::new(vec![tinos_bytes()]).expect("context builds"); + let mut inline = InlineStyleTable::new(1); + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic( + rito_style_contract::GenericFontFamily::Serif, + )]) + .expect("family list"), + 16.0, + 0.0, + ); + style.bidi.writing_mode = rito_style_contract::WritingMode::VerticalRightToLeft; + let style = inline.intern_for_node(0, style).expect("style interns"); + let mut layout = LayoutStyleTable::new(1); + let image_layout = layout + .intern_for_node(0, jgap_image_layout_style()) + .expect("layout style interns"); + let mut tree = FormattingTree::with_styles( + vec![FormattingNode { + style: image_layout, + content: FormattingNodeContent::InlineFlow { + items: vec![ + InlineItem::Text { + text: "一".to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }, + InlineItem::Image { + src: "plate.png".to_owned(), + source: 0, + intrinsic_width: 318.0, + intrinsic_height: 2048.0, + style, + layout_style: image_layout, + fit_contain: false, + object_fit: rito_style_contract::ObjectFit::Fill, + viewport: None, + align: rito_fragment::AtomAlign::Baseline, + baseline_shift_px: 0.0, + }, + ], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { layout, inline }, + ) + .expect("inline tree builds"); + tree.set_strut_styles([(0, style)].into()); + let outcome = context + .layout( + &tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(850.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("box"); + }; + // The plate takes a column of its own after the ideograph's. + let (line, image) = root + .children + .iter() + .filter_map(|line| match line { + Fragment::Line(line) => Some(line), + _ => None, + }) + .find_map(|line| { + line.children.iter().find_map(|child| match child { + Fragment::Image(image) => Some((line, image.rect)), + _ => None, + }) + }) + .expect("a line holds the image"); + // Along the column the image runs its physical height, 850; across + // it, its width scaled by the same 850/2048 onto the 1/64 grid. + assert_eq!((image.width, image.height), (850.0, 131.96875)); + // It sits on the line's central baseline by its middle, so the column + // is exactly as wide as the plate (Chromium: a paragraph holding the + // unscaled 318px plate is 318px wide). + assert_eq!((line.rect.height, image.y), (131.96875, 0.0)); +} diff --git a/crates/rito-inline/src/tests/box_edges.rs b/crates/rito-inline/src/tests/box_edges.rs new file mode 100644 index 000000000..004dfb9e8 --- /dev/null +++ b/crates/rito-inline/src/tests/box_edges.rs @@ -0,0 +1,317 @@ +//! An inline box's start and end sizes as Blink's open and close tag +//! items, pinned against Chromium 156 (Source Han Serif at 16px): a break +//! at a box's start sends its start size down with its content, and a box +//! nested in another takes both start sizes. + +use super::*; + +fn source_han_context() -> ParleyInlineContext { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + ParleyInlineContext::new(vec![source_han]).expect("context builds") +} + +/// One piece of a test paragraph. +enum Piece { + /// Text in the style of the box it sits in (0: the paragraph's own). + Text(&'static str, usize), + Open(usize), + Close(usize), +} + +/// The paragraph `pieces` lays out, its boxes `edges` (padding-left, +/// margin-left, border-left, padding-right per box, box 0 the paragraph's +/// plain style), at `width`: each line's text and where its first glyph +/// run starts. +fn lay_boxes(pieces: &[Piece], edges: &[[f32; 4]], width: f64) -> Vec<(String, f64)> { + use rito_style_contract::{BorderEdge, BorderStyle, CssPx, NonNegativeCssPx}; + let context = source_han_context(); + let mut inline = InlineStyleTable::new(edges.len() + 1); + let length = |px: f32| LengthPercentage::Length(CssPx::new(px).expect("finite")); + let mut styles = vec![inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ), + ) + .expect("style interns")]; + for (index, [padding_left, margin_left, border_left, padding_right]) in edges.iter().enumerate() + { + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + style.fragment.padding.left = + rito_style_contract::NonNegativeLengthPercentage::new(length(*padding_left)); + style.fragment.padding.right = + rito_style_contract::NonNegativeLengthPercentage::new(length(*padding_right)); + style.fragment.margin.left = + rito_style_contract::LengthPercentageOrAuto::Value(length(*margin_left)); + if *border_left > 0.0 { + style.fragment.border.left = BorderEdge { + resolved_width: NonNegativeCssPx::new(*border_left).expect("finite"), + style: BorderStyle::Solid, + color: style.paint.foreground.into(), + }; + } + styles.push( + inline + .intern_for_node(index + 1, style) + .expect("style interns"), + ); + } + let items = pieces + .iter() + .map(|piece| match piece { + Piece::Text(text, style) => InlineItem::Text { + text: (*text).to_owned(), + style: styles[*style], + baseline_shift_px: 0.0, + ruby_annotation: None, + }, + Piece::Open(style) => InlineItem::OpenTag { + style: styles[*style], + }, + Piece::Close(style) => InlineItem::CloseTag { + style: styles[*style], + }, + }) + .collect(); + let text: String = pieces + .iter() + .filter_map(|piece| match piece { + Piece::Text(text, _) => Some(*text), + _ => None, + }) + .collect(); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { items }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(width), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let texts = line_texts(&outcome, &text); + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("inline outcome root is a box fragment"); + }; + root.children + .iter() + .zip(texts) + .map(|(line, text)| { + let Fragment::Line(line) = line else { + panic!("inline children are line fragments"); + }; + let x = line + .children + .iter() + .find_map(|child| match child { + Fragment::Text(run) => Some(line.rect.x + run.rect.x), + _ => None, + }) + .expect("a line holds text"); + (text, x) + }) + .collect() +} + +fn lines(expected: &[(&str, f64)]) -> Vec<(String, f64)> { + expected + .iter() + .map(|(text, x)| ((*text).to_owned(), *x)) + .collect() +} + +const TEN: &str = "永永永永永永永永永永"; + +/// Chromium at 162 and 165px: the ten ideographs before the box fit and +/// end the line, the box's 4px start opens the next one. +#[test] +fn a_break_at_a_box_start_takes_its_start_size_down() { + for width in [162.0, 165.0] { + let laid = lay_boxes( + &[Piece::Text(TEN, 0), Piece::Open(1), Piece::Text("永永", 1)], + &[[4.0, 0.0, 0.0, 0.0]], + width, + ); + assert_eq!(laid, lines(&[(TEN, 0.0), ("永永", 4.0)]), "at {width}px"); + } +} + +/// Chromium at 170px trims the closer before the box to keep it on the +/// line (its start size goes down); at 172px nine ideographs and the +/// closer fit whole. +#[test] +fn a_closer_before_a_box_extends_its_line_without_the_box_start() { + let laid = lay_boxes( + &[ + Piece::Text("永永永永永永永永永永」", 0), + Piece::Open(1), + Piece::Text("永永", 1), + ], + &[[4.0, 0.0, 0.0, 0.0]], + 170.0, + ); + assert_eq!( + laid, + lines(&[("永永永永永永永永永永」", 0.0), ("永永", 4.0)]) + ); + let laid = lay_boxes( + &[ + Piece::Text("永永永永永永永永永」", 0), + Piece::Open(1), + Piece::Text("永永", 1), + ], + &[[4.0, 0.0, 0.0, 0.0]], + 172.0, + ); + assert_eq!(laid, lines(&[("永永永永永永永永永」", 0.0), ("永永", 4.0)])); +} + +/// A box opening inside a word is no break: the word stays whole. +#[test] +fn a_box_start_inside_a_word_is_no_break() { + let laid = lay_boxes( + &[ + Piece::Text("aaaaaa", 0), + Piece::Open(1), + Piece::Text("bbbb", 1), + Piece::Text(" cc", 0), + ], + &[[4.0, 0.0, 0.0, 0.0]], + 60.0, + ); + assert!(laid[0].0.starts_with("aaaaaabbbb"), "{laid:?}"); +} + +/// A space before the box hangs at the end of its line; the box's start +/// opens the next. +#[test] +fn a_box_after_a_space_opens_the_next_line() { + let laid = lay_boxes( + &[ + Piece::Text("永永永永 ", 0), + Piece::Open(1), + Piece::Text("永永", 1), + ], + &[[4.0, 0.0, 0.0, 0.0]], + 80.0, + ); + assert_eq!(laid, lines(&[("永永永永 ", 0.0), ("永永", 4.0)])); +} + +/// Boxes opening at one point take both start sizes (Chromium: padding 2 +/// and margin 3 put the text at 5px), and a border counts as padding does. +#[test] +fn nested_box_starts_and_borders_add_up() { + let laid = lay_boxes( + &[ + Piece::Text(TEN, 0), + Piece::Open(1), + Piece::Open(2), + Piece::Text("永永", 2), + ], + &[[2.0, 0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0]], + 162.0, + ); + assert_eq!(laid, lines(&[(TEN, 0.0), ("永永", 5.0)])); + let laid = lay_boxes( + &[Piece::Text(TEN, 0), Piece::Open(1), Piece::Text("永永", 1)], + &[[1.0, 0.0, 3.0, 0.0]], + 162.0, + ); + assert_eq!(laid, lines(&[(TEN, 0.0), ("永永", 4.0)])); +} + +/// A box opening after a forced break or at the paragraph's start offsets +/// its own line; one whose content does not fit on the first line breaks +/// inside it (Chromium at 162px: nine ideographs after the 4px start). +#[test] +fn a_box_start_offsets_the_line_it_opens() { + let laid = lay_boxes( + &[ + Piece::Text("永永\n", 0), + Piece::Open(1), + Piece::Text("永永", 1), + ], + &[[4.0, 0.0, 0.0, 0.0]], + 162.0, + ); + assert_eq!(laid, lines(&[("永永\n", 0.0), ("永永", 4.0)])); + let laid = lay_boxes( + &[Piece::Open(1), Piece::Text(TEN, 1), Piece::Text("永永", 0)], + &[[4.0, 0.0, 0.0, 0.0]], + 162.0, + ); + assert_eq!(laid, lines(&[("永永永永永永永永永", 4.0), ("永永永", 0.0)])); +} + +/// A box's end size stays with its content: at 162px the ten ideographs +/// and the 4px end do not fit, and Chromium sends the last ideograph down +/// with the end (its second line ends at 16 + 4 + 32 = 52px). +#[test] +fn a_box_end_that_overflows_takes_its_content_down() { + let laid = lay_boxes( + &[Piece::Text(TEN, 1), Piece::Close(1), Piece::Text("永永", 0)], + &[[0.0, 0.0, 0.0, 4.0]], + 162.0, + ); + assert_eq!(laid, lines(&[("永永永永永永永永永", 0.0), ("永永永", 0.0)])); +} + +/// A box's start and end edges follow its writing mode: left and right in +/// horizontal lines (swapped when they run right to left), top and bottom +/// in vertical ones. +#[test] +fn box_edges_follow_the_writing_mode() { + use rito_style_contract::{CssPx, Direction, NonNegativeLengthPercentage, WritingMode}; + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + let side = |px: f32| { + NonNegativeLengthPercentage::new(LengthPercentage::Length(CssPx::new(px).expect("finite"))) + }; + style.fragment.padding.top = side(5.0); + style.fragment.padding.right = side(3.0); + style.fragment.padding.bottom = side(2.0); + style.fragment.padding.left = side(4.0); + let edges = |style: &InlineFormattingStyle| { + ( + style.inline_edge(false).size(None), + style.inline_edge(true).size(None), + ) + }; + assert_eq!(edges(&style), (4.0, 3.0)); + style.bidi.direction = Direction::RightToLeft; + assert_eq!(edges(&style), (3.0, 4.0)); + style.bidi.direction = Direction::LeftToRight; + style.bidi.writing_mode = WritingMode::VerticalRightToLeft; + assert_eq!(edges(&style), (5.0, 2.0)); +} diff --git a/crates/rito-inline/src/tests/break_rules.rs b/crates/rito-inline/src/tests/break_rules.rs new file mode 100644 index 000000000..ff614163c --- /dev/null +++ b/crates/rito-inline/src/tests/break_rules.rs @@ -0,0 +1,138 @@ +//! Where a line may break, as Chromium 156 breaks: the boundaries of +//! mixed strings under each content language and `line-break` value its +//! ICU opens a different rule set for. + +use crate::{BreakContext, BreakOpportunities, RuleSet}; +use rito_style_contract::LineBreak; + +fn line_break(value: &str) -> LineBreak { + match value { + "auto" => LineBreak::Auto, + "normal" => LineBreak::Normal, + "loose" => LineBreak::Loose, + "strict" => LineBreak::Strict, + other => panic!("unknown line-break {other}"), + } +} + +/// Each boundary of `text` as `1` (a break opportunity), `0` (none) or `?` +/// (left to the dictionary). +fn verdicts(language: &str, value: &str, text: &str) -> String { + let rule_set = + RuleSet::for_content((language != "none").then_some(language), line_break(value)); + let opportunities = BreakOpportunities::new(text, |_| BreakContext { + rule_set, + word_break: rito_style_contract::WordBreak::Normal, + anywhere: false, + collapses: true, + }); + text.char_indices() + .skip(1) + .map(|(offset, _)| match opportunities.before(offset) { + Some(true) => '1', + Some(false) => '0', + None => '?', + }) + .collect() +} + +/// Each case's boundaries as Chromium 156 breaks them (the pinned serif at +/// 16px, each boundary probed by a paragraph as wide as the text before +/// it): `1` a break opportunity, `0` none. +const CASES: &[(&str, &str, &str, &str)] = &[ + // A wave dash may start a line in Japanese `normal` and `loose` and in + // Chinese, never by default in Japanese nor in other languages. + ("ja", "auto", "ちょっと〜〜!」って", "111000011"), + ("ja", "normal", "ちょっと〜〜!」って", "111110011"), + ("ja", "loose", "ちょっと〜〜!」って", "111111011"), + ("zh-CN", "auto", "ちょっと〜〜!」って", "111110011"), + ("en", "auto", "ちょっと〜〜!」って", "111000011"), + // ...but never after an opening bracket or quotation mark. + ("ja", "normal", "(〜)", "00"), + ("ja", "normal", "“〜”", "00"), + ("ja", "normal", "あ 〜 い", "0101"), + // Chinese reads the curly double quotes as brackets; elsewhere a + // quotation mark breaks only between East Asian characters. + ("zh-CN", "auto", "怕”——可见", "01011"), + ("ja", "auto", "怕”——可见", "00011"), + ("en", "auto", "怕”——可见", "00011"), + ("en", "auto", "我們說“好”吧", "111001"), + // Numbers keep their prefixes and postfixes; full-width digits are + // ideographs. + ( + "en", + "auto", + "100% 50$ $50 €40 40€ ¥100 100¥", + "00001000100010001000100001000", + ), + ( + "ja", + "loose", + "100% 50$ $50 €40 40€ ¥100 100¥", + "00001000100011001000100001000", + ), + ("zh-CN", "auto", "价格(123)%便宜", "110110111"), + // `loose` breaks before iteration marks and centered punctuation in + // Japanese, and between an ideograph and a hyphen, whose word then + // stays together. + ("ja", "normal", "々ゝヽ・:;!?‼⁇あ", "0000000001"), + ("ja", "loose", "々ゝヽ・:;!?‼⁇あ", "1111111111"), + ("ja", "normal", "‐あ‐い", "101"), + ("ja", "loose", "‐あ‐い", "111"), + // A space then an ideographic space: the line ends after the space. + ("ja", "auto", "上下 \u{3000}左右", "10101"), +]; + +#[test] +fn break_opportunities_follow_chromium_per_language_and_line_break() { + for &(language, value, text, expected) in CASES { + assert_eq!( + verdicts(language, value, text), + expected, + "{language} line-break: {value} breaks {text:?}" + ); + } +} + +/// The instrument: every row of the browser probe's strings, as this +/// engine breaks them, in the probe's shape. +/// `RITO_BREAK_STRINGS=in.tsv RITO_BREAK_OUT=out.tsv cargo test -p rito-inline +/// break_strings_dump -- --ignored` +#[test] +#[ignore = "instrument: compares against the browser probe's output"] +fn break_strings_dump() { + let input = std::env::var("RITO_BREAK_STRINGS").expect("RITO_BREAK_STRINGS"); + let output = std::env::var("RITO_BREAK_OUT").expect("RITO_BREAK_OUT"); + let unescape = |raw: &str| { + let mut text = String::new(); + let mut rest = raw; + while let Some(at) = rest.find("\\u{") { + text.push_str(&rest[..at]); + let close = rest[at..].find('}').expect("escape closes") + at; + let code = u32::from_str_radix(&rest[at + 3..close], 16).expect("hex escape"); + text.push(char::from_u32(code).expect("scalar")); + rest = &rest[close + 1..]; + } + text.push_str(rest); + text + }; + let rows: Vec = std::fs::read_to_string(input) + .expect("strings read") + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let mut fields = line.splitn(3, '\t'); + let (language, value, raw) = ( + fields.next().expect("language"), + fields.next().expect("line-break"), + fields.next().expect("text"), + ); + let text = unescape(raw); + format!( + "{language}\t{value}\t{text}\t{}", + verdicts(language, value, &text) + ) + }) + .collect(); + std::fs::write(output, rows.join("\n") + "\n").expect("rows write"); +} diff --git a/crates/rito-inline/src/tests/breaks_and_spacing.rs b/crates/rito-inline/src/tests/breaks_and_spacing.rs index ee6f894f5..23abe637a 100644 --- a/crates/rito-inline/src/tests/breaks_and_spacing.rs +++ b/crates/rito-inline/src/tests/breaks_and_spacing.rs @@ -320,12 +320,14 @@ fn observe_adjacent_bordered_span_run_boxes() { LengthPercentage::Length(rito_style_contract::CssPx::new(3.0).expect("finite")), ); let style_id = inline.intern_for_node(index, style).expect("style interns"); + items.push(InlineItem::OpenTag { style: style_id }); items.push(InlineItem::Text { text: text.to_owned(), style: style_id, baseline_shift_px: 0.0, ruby_annotation: None, }); + items.push(InlineItem::CloseTag { style: style_id }); } let nodes = vec![FormattingNode { style: rito_style_contract::LayoutStyleId::from_raw(0), @@ -496,6 +498,7 @@ fn an_inline_margin_indents_its_forced_break_line() { baseline_shift_px: 0.0, ruby_annotation: None, }, + InlineItem::OpenTag { style: badge }, InlineItem::Text { text: "的".to_owned(), style: badge, @@ -553,8 +556,7 @@ fn the_page_clamp_scales_the_authored_image_box_uniformly() { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LengthPercentageOrAuto, ListMarkerStyle, - MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, PhysicalSides, Position, - PreferredSize, + MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, PreferredSize, }; let auto = LengthPercentageOrAuto::Auto; let zero_padding = @@ -577,6 +579,9 @@ fn the_page_clamp_scales_the_authored_image_box_uniformly() { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -589,8 +594,11 @@ fn the_page_clamp_scales_the_authored_image_box_uniformly() { )), height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -713,6 +721,73 @@ fn a_cj_starter_may_open_a_line_unless_strict() { ); } +/// An ideographic space that overflows a line hangs past its end when the +/// line may break after it, as Chromium hangs other space separators: in a +/// 160px line of 16px ideographs the space after a full line's `!` hangs +/// and the line breaks after it. Before a closing bracket the line may not +/// break there, so the space does not hang and the line breaks before the +/// last ideograph that can open the next one (Chromium's DOM, both cases). +#[test] +fn an_ideographic_space_hangs_where_the_line_may_break_after_it() { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); + let layout_lines = |text: &str| { + let style = plain_paragraph_style( + rito_style_contract::FontFamilies::new(vec![FontFamily::Named(FontFamilyName::new( + "NoSuchFace", + ))]) + .expect("family list"), + 16.0, + 0.0, + ); + let mut inline = InlineStyleTable::new(1); + let style_id = inline.intern_for_node(0, style).expect("style interns"); + let nodes = vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.to_owned(), + style: style_id, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }]; + let tree = FormattingTree::with_styles( + nodes, + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(160.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + line_texts(&outcome, text) + }; + assert_eq!( + layout_lines("一二三四五六七八九!\u{3000}十一"), + vec!["一二三四五六七八九!\u{3000}".to_owned(), "十一".to_owned()], + ); + assert_eq!( + layout_lines("一二三四五六七八九!\u{3000}」十"), + vec!["一二三四五六七八".to_owned(), "九!\u{3000}」十".to_owned()], + ); +} + /// A registered named font must win over the pinned fallback when the /// style names it: the two faces have different advances for the same /// glyphs, so the line width tells which one shaped. @@ -1293,10 +1368,11 @@ fn a_fullwidth_comma_keeps_its_following_opening_quote() { ); } -/// A closing curly quote breaks before an em-dash pair: the quote -/// closes its line and the dashes open the next (pinned-Chromium -/// b112 line at 640: 怕” | ——可见). Treating quote-then-dash as -/// unbreakable dragged the quote down with the pair. +/// A closing curly quote breaks before an em-dash pair in Chinese text: +/// the quote closes its line and the dashes open the next +/// (pinned-Chromium b112 line at 640, the book's `zh-CN`: 怕” | ——可见). +/// Chinese line breaking reads the curly double quotes as brackets; in a +/// language without that tailoring the quote stays with the dashes. #[test] fn a_closing_quote_breaks_before_a_dash_pair() { let source_han = std::fs::read(concat!( @@ -1316,6 +1392,7 @@ fn a_closing_quote_breaks_before_a_dash_pair() { 32.0, ); style.text_flow.text_align = TextAlign::Justify; + style.text_flow.language = Some(rito_style_contract::LanguageTag::new("zh-CN")); let style_id = inline.intern_for_node(0, style).expect("style interns"); let text = "这就是Master绮礼的指示。就连战斗能力最为低下的Assassin与其交锋时都\u{201C}不必惧怕\u{201D}\u{2014}\u{2014}可见时臣召唤出来的Archer的英灵,一定是非常令绮礼失望的吧。"; let tree = FormattingTree::with_styles( @@ -1369,3 +1446,124 @@ fn a_closing_quote_breaks_before_a_dash_pair() { "the dash pair opens the second line, the quote stays up" ); } + +/// A wave dash may start a line in Japanese text under `line-break: +/// normal` but not by default: Chromium's DOM at 16px in an 850px line +/// breaks the book's sentence between the two dashes under `normal` and +/// before よ under `auto`. +#[test] +fn a_wave_dash_starts_a_line_under_japanese_normal_breaking() { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); + let text = "「取り返せるわけがない〜〜!\u{3000}一度あんなのになっちゃったら、もう今さら普通のキャラに戻るなんてムリだよ〜〜!」"; + let layout_lines = |line_break: rito_style_contract::LineBreak| { + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Named(FontFamilyName::new("NoSuchFace"))]) + .expect("family list"), + 16.0, + 0.0, + ); + style.text_flow.language = Some(rito_style_contract::LanguageTag::new("ja")); + style.text_flow.line_break = line_break; + let mut inline = InlineStyleTable::new(1); + let style_id = inline.intern_for_node(0, style).expect("style interns"); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.to_owned(), + style: style_id, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(850.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + line_texts(&outcome, text) + }; + let head = "「取り返せるわけがない〜〜!\u{3000}一度あんなのになっちゃったら、もう今さら普通のキャラに戻るなんてムリだ"; + assert_eq!( + layout_lines(rito_style_contract::LineBreak::Normal), + vec![format!("{head}よ〜"), "〜!」".to_owned()], + ); + assert_eq!( + layout_lines(rito_style_contract::LineBreak::Auto), + vec![head.to_owned(), "よ〜〜!」".to_owned()], + ); +} + +/// A line's last glyph is measured as Chromium reshapes it at the line's +/// end, without the space that hangs after it: Tinos kerns "A" against +/// the space, so "…the hour. A" fits 640px only kerned (639.16) and +/// overflows unkerned (640.05, past the 1/64px the fit allows); Chromium's +/// DOM breaks before the "A" (pinned Tinos at 16px, a 16px first-line +/// indent, 640px wide). +#[test] +fn a_line_end_glyph_loses_its_kerning_with_the_hanging_space() { + let context = ParleyInlineContext::new(vec![tinos_bytes()]).expect("context builds"); + let text = "Harry looked around and saw that Mrs. Weasley was not alone, despite the lateness of the hour. A young witch with a pale, heart-shaped face and mousy brown hair was sitting at the table clutching a large mug between her hands."; + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Named(FontFamilyName::new("NoSuchFace"))]) + .expect("family list"), + 16.0, + 16.0, + ); + let mut inline = InlineStyleTable::new(1); + let style_id = inline.intern_for_node(0, style).expect("style interns"); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.to_owned(), + style: style_id, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(640.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let lines = line_texts(&outcome, text); + assert_eq!( + lines[0], + "Harry looked around and saw that Mrs. Weasley was not alone, despite the lateness of the hour. ", + ); + assert!(lines[1].starts_with("A young witch"), "{lines:?}"); +} diff --git a/crates/rito-inline/src/tests/clusters.rs b/crates/rito-inline/src/tests/clusters.rs index b7edf4a9f..ea9012f78 100644 --- a/crates/rito-inline/src/tests/clusters.rs +++ b/crates/rito-inline/src/tests/clusters.rs @@ -307,6 +307,7 @@ fn a_spread_ruby_base_keeps_one_origin_per_cluster_after_merging() { text: "Emnetwiht".to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }, text_item("」"), @@ -469,6 +470,45 @@ fn clusters_reached_by_a_bare_advance_join_the_one_before() { ); } +/// A ligature draws only in one string from its start: its later +/// clusters join the start whatever their split advances, and the cluster +/// after it joins when the whole ligature steps by its glyph's advance +/// (an embedded face's calt pair of em dashes drawn apart rastered its two +/// isolated dashes a pixel above the browser's joined bar). +#[test] +fn a_ligature_draws_in_one_string_from_its_start() { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + let run = context.measure_run(&style, "fix"); + let bytes: Vec = run.clusters.iter().map(|c| c.byte).collect(); + assert_eq!( + bytes, + vec![0, 1, 2], + "fi ligates into two clusters: {run:?}" + ); + assert_eq!( + run.clusters.iter().map(|c| c.joins).collect::>(), + vec![false, true, true] + ); + assert_eq!( + run.clusters + .iter() + .map(|c| c.continues_ligature) + .collect::>(), + vec![false, true, false] + ); +} + /// A line's text in one inline item is one fragment, as the browser keeps /// one text item per inline item on a line: the pieces it was placed in /// are its segments, each placed from its own origin and starting a string diff --git a/crates/rito-inline/src/tests/envelopes_and_ruby.rs b/crates/rito-inline/src/tests/envelopes_and_ruby.rs index b32ad5878..6592c023f 100644 --- a/crates/rito-inline/src/tests/envelopes_and_ruby.rs +++ b/crates/rito-inline/src/tests/envelopes_and_ruby.rs @@ -94,8 +94,8 @@ fn a_super_shifted_marker_image_grows_the_line_with_a_consistent_baseline() { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleTable, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, - PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, + PreferredSize, }; let context = ParleyInlineContext::new(vec![source_han_bytes()]).expect("context builds"); let mut inline = InlineStyleTable::new(2); @@ -138,6 +138,9 @@ fn a_super_shifted_marker_image_grows_the_line_with_a_consistent_baseline() { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -148,8 +151,11 @@ fn a_super_shifted_marker_image_grows_the_line_with_a_consistent_baseline() { LengthPercentage::Length(CssPx::new(14.390625).expect("finite")), )), max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -192,7 +198,7 @@ fn a_super_shifted_marker_image_grows_the_line_with_a_consistent_baseline() { object_fit: rito_style_contract::ObjectFit::Fill, viewport: None, baseline_shift_px: 6.328125, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, }, InlineItem::Text { text: ",甘夏老师".to_owned(), @@ -246,8 +252,8 @@ fn images_lay_out_as_atomic_inlines_with_display_geometry() { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleTable, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, - PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, + PreferredSize, }; let context = ParleyInlineContext::new(vec![tinos_bytes()]).expect("context builds"); let mut inline = InlineStyleTable::new(1); @@ -279,6 +285,9 @@ fn images_lay_out_as_atomic_inlines_with_display_geometry() { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -287,8 +296,11 @@ fn images_lay_out_as_atomic_inlines_with_display_geometry() { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -331,7 +343,7 @@ fn images_lay_out_as_atomic_inlines_with_display_geometry() { object_fit: rito_style_contract::ObjectFit::Fill, viewport: None, baseline_shift_px: 0.0, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, }, InlineItem::Text { text: " after the picture.".to_owned(), @@ -519,10 +531,15 @@ fn glyph_runs_carry_monotonic_geometry_and_indent_offsets_the_first_run() { } } -/// A ruby base splits across lines like plain CJK text (measured: -/// 黄金妖精/Leprechaun wraps as 黄金妖|精 — every base character -/// boundary is an ordinary break point; the annotation itself rides -/// only the first segment, which the paint layer enforces). +/// A ruby base longer than four glyphs splits across lines like plain CJK +/// text (measured: 黄金妖精/Leprechaun wraps as 黄金妖|精 — every base +/// character boundary is an ordinary break point; the annotation itself +/// rides only the first segment, which the paint layer enforces), while a +/// base of at most four glyphs under an annotation of at most eight is one +/// unit that moves down whole (Blink's `IsMonolithicRuby`), glyphs counted +/// as shaped: か with a combining voiced mark is one. Chromium's DOM at +/// 32px in a 165px line: 中中中中中|文中文中, 中中中中|中文, +/// 中中中中|かが(combining)かか and 中中中中か|かかかか. #[test] fn a_ruby_base_breaks_across_lines_like_plain_text() { let source_han = std::fs::read(concat!( @@ -531,64 +548,79 @@ fn a_ruby_base_breaks_across_lines_like_plain_text() { )) .expect("pinned serif reads"); let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); - let style = plain_paragraph_style( - FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) - .expect("family list"), - 32.0, - 0.0, - ); - let mut inline = InlineStyleTable::new(1); - let interned = inline.intern_for_node(0, style).expect("style interns"); - let nodes = vec![FormattingNode { - style: rito_style_contract::LayoutStyleId::from_raw(0), - content: FormattingNodeContent::InlineFlow { - items: vec![ - InlineItem::Text { - text: "中中中中".to_owned(), - style: interned, - baseline_shift_px: 0.0, - ruby_annotation: None, - }, - InlineItem::Text { - text: "中文".to_owned(), - style: interned, - baseline_shift_px: 0.0, - ruby_annotation: Some(rito_fragment::RubyAnnotation { - text: "an".to_owned(), - size_ratio: 0.5, - align: rito_style_contract::RubyAlign::SpaceAround, - }), - }, - ], - }, - children: Vec::new(), - }]; - let tree = FormattingTree::with_styles( - nodes, - FormattingNodeId(0), - rito_fragment::FormattingTreeStyles { - layout: LayoutStyleTable::new(0), - inline, - }, - ) - .expect("inline tree builds"); - // 165px: four 32px lead glyphs plus the first base glyph fit - // (160), so the base splits after its first character exactly as - // plain text would. - let outcome = context - .layout( - &tree, - tree.root(), - &ConstraintSpace::continuous(165.0), - None, - &CancelFlag::new(), + let lines = |base: &str| { + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 32.0, + 0.0, + ); + let mut inline = InlineStyleTable::new(1); + let interned = inline.intern_for_node(0, style).expect("style interns"); + let nodes = vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![ + InlineItem::Text { + text: "中中中中".to_owned(), + style: interned, + baseline_shift_px: 0.0, + ruby_annotation: None, + }, + InlineItem::Text { + text: base.to_owned(), + style: interned, + baseline_shift_px: 0.0, + ruby_annotation: Some(rito_fragment::RubyAnnotation { + text: "an".to_owned(), + size_ratio: 0.5, + align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, + }), + }, + ], + }, + children: Vec::new(), + }]; + let tree = FormattingTree::with_styles( + nodes, + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, ) - .expect("layout succeeds"); - let lines = line_texts(&outcome, "中中中中中文"); + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(165.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + line_texts(&outcome, &format!("中中中中{base}")) + }; assert_eq!( - lines, - vec!["中中中中中".to_owned(), "文".to_owned()], - "the annotated base breaks at an ordinary character boundary" + lines("中文中文中"), + vec!["中中中中中".to_owned(), "文中文中".to_owned()], + "a long annotated base breaks at an ordinary character boundary" + ); + assert_eq!( + lines("中文"), + vec!["中中中中".to_owned(), "中文".to_owned()], + "a short ruby moves down whole" + ); + assert_eq!( + lines("かか\u{3099}かか"), + vec!["中中中中".to_owned(), "かか\u{3099}かか".to_owned()], + "five characters shaped into four glyphs are a short ruby" + ); + assert_eq!( + lines("かかかかか"), + vec!["中中中中か".to_owned(), "かかかか".to_owned()], + "a base of five glyphs breaks" ); } @@ -637,6 +669,7 @@ fn a_ruby_split_whose_annotation_overflows_rewinds_to_the_item_start() { text: "wwwwww".to_owned(), size_ratio: 0.5, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }, ], @@ -797,6 +830,7 @@ fn a_wide_ruby_annotation_spreads_its_base_with_interior_gaps() { text: "annotation".to_owned(), size_ratio: ratio, align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, }), }, InlineItem::Text { @@ -916,6 +950,7 @@ fn a_justified_line_gives_a_wide_ruby_column_no_share_of_its_own() { text: "送葬者".to_owned(), size_ratio: 0.55, align: RubyAlign::Center, + continues_ruby: false, }), }, text("和莱登的部队了,我记得他们两个认识也有两年了吧。"), @@ -1007,3 +1042,397 @@ fn a_justified_line_gives_a_wide_ruby_column_no_share_of_its_own() { "one share per boundary after it: {steps:?}" ); } + +/// An inline image's layout style: auto-sized, in flow, unpadded. +fn plain_image_layout(layout: &mut LayoutStyleTable) -> rito_style_contract::LayoutStyleId { + use rito_style_contract::{ + AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, + LayoutDisplayOutside, LayoutFormattingStyle, ListMarkerStyle, MaximumSize, MinimumSize, + Overflow, PageBreak, Position, PreferredSize, + }; + let auto = LengthPercentageOrAuto::Auto; + let zero = + NonNegativeLengthPercentage::new(LengthPercentage::Length(CssPx::new(0.0).expect("zero"))); + let zero_px = rito_style_contract::NonNegativeCssPx::new(0.0).expect("zero"); + layout + .intern_for_node( + 0, + LayoutFormattingStyle { + display: LayoutDisplay { + outside: LayoutDisplayOutside::Inline, + inside: LayoutDisplayInside::Flow, + is_list_item: false, + }, + margin: sides(auto), + padding: sides(zero), + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), + box_sizing: rito_style_contract::BoxSizing::ContentBox, + justify_content: JustifyContent::Normal, + align_items: AlignItems::Normal, + break_before: PageBreak::Auto, + break_after: PageBreak::Auto, + width: PreferredSize::Auto, + height: PreferredSize::Auto, + max_width: MaximumSize::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, + clear: Clear::None, + float: Float::None, + overflow: Overflow::Visible, + list_style_type: ListMarkerStyle::None, + position: Position::Static, + inset: sides(auto), + vertical_align: rito_style_contract::CellVerticalAlign::Baseline, + border_spacing: (zero_px, zero_px), + border_collapse: false, + object_fit: rito_style_contract::ObjectFit::Fill, + }, + ) + .expect("layout style interns") +} + +/// A line rewound to the start of a ruby item holds everything before +/// that item, an inline image included: the rewind held a count of the +/// text characters before the item, and the breaker counts an image as +/// one, so the line ended one character early and the image went down +/// with the ruby. +#[test] +fn a_ruby_split_rewind_keeps_an_image_before_the_ruby_on_the_line() { + let context = ParleyInlineContext::new(vec![source_han_bytes()]).expect("context builds"); + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 32.0, + 0.0, + ); + let mut inline = InlineStyleTable::new(1); + let interned = inline.intern_for_node(0, style).expect("style interns"); + let mut layout = LayoutStyleTable::new(1); + let image_layout = plain_image_layout(&mut layout); + let items = vec![ + InlineItem::Text { + text: "中中".to_owned(), + style: interned, + baseline_shift_px: 0.0, + ruby_annotation: None, + }, + InlineItem::Image { + source: 0, + src: "images/note.png".to_owned(), + intrinsic_width: 32.0, + intrinsic_height: 32.0, + style: interned, + layout_style: image_layout, + fit_contain: false, + viewport: None, + baseline_shift_px: 0.0, + align: rito_fragment::AtomAlign::Baseline, + object_fit: rito_style_contract::ObjectFit::Fill, + }, + InlineItem::Text { + text: "中文中".to_owned(), + style: interned, + baseline_shift_px: 0.0, + ruby_annotation: Some(rito_fragment::RubyAnnotation { + text: "wwwwww".to_owned(), + size_ratio: 0.5, + align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, + }), + }, + ]; + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { items }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { layout, inline }, + ) + .expect("inline tree builds"); + // The geometry of the rewind above with an image for one lead glyph. + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(165.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let text = "中中中文中"; + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("root is a box"); + }; + // Each line as its text, an image as `▣`. + let lines: Vec = root + .children + .iter() + .map(|line| { + let Fragment::Line(line) = line else { + panic!("a line"); + }; + line.children + .iter() + .map(|child| match child { + Fragment::Text(run) => { + text[run.text_start as usize..run.text_end as usize].to_owned() + } + Fragment::Image(_) => "▣".to_owned(), + _ => String::new(), + }) + .collect() + }) + .collect(); + assert_eq!(lines, vec!["中中▣".to_owned(), "中文中".to_owned()]); +} + +/// A justified paragraph aligns a wide-annotation ruby's base to the +/// line's start edge and a justified line's base to its end edge, where +/// elsewhere it sits centered under the annotation (Blink's +/// `ApplyRubyAlign`; measured in pinned Chromium on a 150px line of 16px +/// ideographs with 瞬 under しゅん: the base inks at the line start in a +/// justified paragraph, its first and last lines alike, 4px in under +/// start alignment, and 8px past its column's start at a justified +/// line's end). +#[test] +fn a_justified_line_aligns_a_wide_ruby_base_to_its_edges() { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); + // The painted start of the ruby base on each line holding it. + let base_starts = |before: &str, after: &str, align: TextAlign| -> Vec { + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + style.text_flow.text_align = align; + let mut inline = InlineStyleTable::new(1); + let style = inline.intern_for_node(0, style).expect("style interns"); + let text = |t: &str| InlineItem::Text { + text: t.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }; + let mut items = Vec::new(); + if !before.is_empty() { + items.push(text(before)); + } + items.push(InlineItem::Text { + text: "瞬".to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: Some(rito_fragment::RubyAnnotation { + text: "しゅん".to_owned(), + size_ratio: 0.5, + align: RubyAlign::SpaceAround, + continues_ruby: false, + }), + }); + items.push(text(after)); + let base_start = before.len() as u32; + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { items }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(150.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("inline outcome root is a box fragment"); + }; + root.children + .iter() + .filter_map(|child| match child { + Fragment::Line(line) => Some(line), + _ => None, + }) + .flat_map(|line| { + line.children.iter().filter_map(move |child| match child { + Fragment::Text(run) if run.text_start == base_start => { + Some(line.rect.x + run.rect.x + run.ruby_center_shift_px) + } + _ => None, + }) + }) + .collect() + }; + let rest = "一二三四五六七八九十一二三四五六七八九十"; + let at = |starts: Vec, expected: f64, case: &str| { + assert_eq!(starts.len(), 1, "{case}: one base run: {starts:?}"); + assert!( + (starts[0] - expected).abs() < 1e-6, + "{case}: base at {} vs {expected}", + starts[0] + ); + }; + at( + base_starts("", rest, TextAlign::Justify), + 0.0, + "first line start, justified", + ); + at( + base_starts("", rest, TextAlign::Left), + 4.0, + "line start, start-aligned", + ); + at( + base_starts( + "一二三四五六七八", + "九十一二三四五六七八九十", + TextAlign::Justify, + ), + 132.0, + "justified line end", + ); + at( + base_starts( + "一二三四五六七八", + "九十一二三四五六七八九十", + TextAlign::Left, + ), + 128.0, + "start-aligned line end", + ); + at( + base_starts("一二三四五六七八九", "十", TextAlign::Justify), + 0.0, + "last line start, justified", + ); +} + +/// Each inline item of a justified line ends on the LayoutUnit grid at +/// the ceiling of its exact advance — its fixed-point glyph advances plus +/// its shares — however little the shares lift it past a grid point: +/// pinned Chromium sets this book line (16px, a 0.36 share) with every +/// ruby pair and the ideographic space before them 16.375 apart, where a +/// float margin left them on 16.359375 and the ruby boxes rounded a pixel +/// off. +#[test] +fn a_justified_item_ends_on_the_grid_ceiling_of_its_exact_advance() { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + style.text_flow.text_align = TextAlign::Justify; + let mut inline = InlineStyleTable::new(1); + let style = inline.intern_for_node(0, style).expect("style interns"); + let text = |t: &str| InlineItem::Text { + text: t.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }; + let ruby = |t: &str, a: &str, continues_ruby: bool| InlineItem::Text { + text: t.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: Some(rito_fragment::RubyAnnotation { + text: a.to_owned(), + size_ratio: 0.5, + align: RubyAlign::SpaceAround, + continues_ruby, + }), + }; + let items = vec![ + text(" "), + ruby("蒼", "あお", false), + ruby("依", "い", true), + text("、"), + ruby("朱", "あか", false), + ruby("音", "ね", true), + text("、"), + ruby("橙", "とう", false), + ruby("花", "か", true), + text("、マリンなどなど……本来その全員と一緒に"), + ruby("観", "み", false), + text("たかったところだが、さすがに席数には制限があり、あくまで"), + ]; + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { items }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + tree.root(), + &ConstraintSpace::continuous(850.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let Fragment::Box(root) = &outcome.fragments.root else { + panic!("inline outcome root is a box fragment"); + }; + let Some(Fragment::Line(line)) = root.children.first() else { + panic!("outcome has a first line"); + }; + let xs: Vec = placed(line.children.iter().filter_map(|child| match child { + Fragment::Text(run) => Some(run), + _ => None, + })) + .iter() + .flat_map(|piece| { + piece + .clusters + .iter() + .map(move |cluster| piece.x + cluster.x) + }) + .collect(); + //  蒼依、朱音、橙花、 as Chromium places them (Range, page 133). + let expected = [ + 0.0, 16.375, 32.75, 49.125, 65.5, 81.875, 98.25, 114.625, 131.0, 147.375, + ]; + for (index, (x, want)) in xs.iter().zip(expected).enumerate() { + assert!( + (x - want).abs() < 1e-6, + "glyph {index} at {x}, want {want}: {xs:?}" + ); + } + // The plain text after them keeps the float share. + assert!(((xs[11] - xs[10]) - 16.36).abs() < 1e-3, "{xs:?}"); +} diff --git a/crates/rito-inline/src/tests/fixtures/float_band_line_edges.txt b/crates/rito-inline/src/tests/fixtures/float_band_line_edges.txt new file mode 100644 index 000000000..e9aee1c5c --- /dev/null +++ b/crates/rito-inline/src/tests/fixtures/float_band_line_edges.txt @@ -0,0 +1,50 @@ +150 | 0..21 Regular, 21..45 Regular, 45..69 Regular, 69..90 Regular, 90..114 Regular, 114..141 Regular, 141..168 Regular, 168..195 Regular, 195..222 Regular, 222..249 Regular, 249..276 Regular, 276..282 None | +151 | 0..21 Regular, 21..45 Regular, 45..69 Regular, 69..90 Regular, 90..114 Regular, 114..141 Regular, 141..168 Regular, 168..195 Regular, 195..222 Regular, 222..249 Regular, 249..276 Regular, 276..282 None | +152 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +153 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +154 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +155 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +156 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +157 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +158 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +159 | 0..24 Regular, 24..48 Regular, 48..72 Regular, 72..96 Regular, 96..126 Regular, 126..153 Regular, 153..183 Regular, 183..210 Regular, 210..237 Regular, 237..264 Regular, 264..282 None | 180..183 -8.0 +160 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +161 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +162 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +163 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +164 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +165 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +166 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +167 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..135 Regular, 135..165 Regular, 165..195 Regular, 195..225 Regular, 225..255 Regular, 255..282 None | +168 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +169 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +170 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +171 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +172 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +173 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +174 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +175 | 0..24 Regular, 24..51 Regular, 51..78 Regular, 78..105 Regular, 105..138 Regular, 138..168 Regular, 168..198 Regular, 198..228 Regular, 228..258 Regular, 258..282 None | +176 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +177 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +178 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +179 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +180 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +181 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +182 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +183 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | 27..30 -8.0, 57..60 -8.0, 87..90 -8.0, 117..120 -8.0 +184 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +185 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +186 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +187 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +188 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +189 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +190 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +191 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..153 Regular, 153..186 Regular, 186..219 Regular, 219..252 Regular, 252..282 None | +192 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +193 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +194 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +195 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +196 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +197 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +198 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | +199 | 0..30 Regular, 30..60 Regular, 60..90 Regular, 90..120 Regular, 120..156 Regular, 156..192 Regular, 192..228 Regular, 228..264 Regular, 264..282 None | diff --git a/crates/rito-inline/src/tests/fixtures/ruby_split_spreads.txt b/crates/rito-inline/src/tests/fixtures/ruby_split_spreads.txt new file mode 100644 index 000000000..3249b9597 --- /dev/null +++ b/crates/rito-inline/src/tests/fixtures/ruby_split_spreads.txt @@ -0,0 +1,64 @@ +96 | 0..15 Regular, 15..27 Regular, 27..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +97 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +98 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +99 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +100 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +101 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +102 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +103 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +104 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +105 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +106 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +107 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +108 | 0..15 Regular, 15..30 Regular, 30..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625, 15..21 3.3639762, 21..24 1.6819881 +109 | 0..15 Regular, 15..27 Regular, 27..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +110 | 0..15 Regular, 15..27 Regular, 27..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +111 | 0..15 Regular, 15..27 Regular, 27..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +112 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +113 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +114 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +115 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +116 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +117 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +118 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +119 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +120 | 0..15 Regular, 15..27 Regular, 27..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 8.0625 +121 | 0..18 Regular, 18..33 Regular, 33..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +122 | 0..18 Regular, 18..33 Regular, 33..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +123 | 0..18 Regular, 18..33 Regular, 33..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +124 | 0..18 Regular, 18..33 Regular, 33..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +125 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +126 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +127 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +128 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +129 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +130 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +131 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +132 | 0..18 Regular, 18..36 Regular, 36..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349 +133 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349, 21..24 7.36658, 24..27 3.68329 +134 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349, 21..24 7.36658, 24..27 3.68329 +135 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349, 21..24 7.36658, 24..27 3.68329 +136 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 15..18 8.417349, 21..24 7.36658, 24..27 3.68329 +137 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +138 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +139 | 0..21 Regular, 21..42 Regular, 42..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +140 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +141 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +142 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +143 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +144 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +145 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +146 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +147 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +148 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +149 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +150 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +151 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +152 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +153 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +154 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +155 | 0..21 Regular, 21..45 Regular, 45..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +156 | 0..21 Regular, 21..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +157 | 0..21 Regular, 21..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +158 | 0..21 Regular, 21..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 +159 | 0..21 Regular, 21..48 Regular, 48..51 None | 15..18 12.067708, 18..21 12.067708, 21..24 12.067708, 24..27 4.0625, 21..24 7.36658, 24..27 3.68329 diff --git a/crates/rito-inline/src/tests/line_decisions.rs b/crates/rito-inline/src/tests/line_decisions.rs new file mode 100644 index 000000000..817b7426e --- /dev/null +++ b/crates/rito-inline/src/tests/line_decisions.rs @@ -0,0 +1,725 @@ +//! Line edges decided one line at a time: a paragraph is built (shaped) +//! at most twice however many of its lines take a line-end trim, a pair +//! trim a break separates or a ruby spread, and the lines are pinned +//! against pinned Chromium (Source Han Serif at 16px). + +use super::*; + +fn source_han_context() -> ParleyInlineContext { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + ParleyInlineContext::new(vec![source_han]).expect("context builds") +} + +fn serif_paragraph(text: &str) -> FormattingTree { + let mut inline = InlineStyleTable::new(1); + let style = inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ), + ) + .expect("style interns"); + FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds") +} + +fn lay(context: &ParleyInlineContext, tree: &FormattingTree, width: f64) -> LayoutOutcome { + context + .layout( + tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(width), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds") +} + +#[test] +fn every_line_taking_a_line_end_trim_builds_the_paragraph_at_most_twice() { + let context = source_han_context(); + let segment = format!("{}」", "永".repeat(10)); + let text = segment.repeat(40); + let outcome = lay(&context, &serif_paragraph(&text), 170.0); + // Chromium: forty lines, each ten ideographs and the closer at half + // width (168px). + let lines = line_texts(&outcome, &text); + assert_eq!(lines.len(), 40, "{lines:?}"); + assert!(lines.iter().all(|line| *line == segment), "{lines:?}"); + assert!( + context.counts.builds.get() <= 2, + "built {} times", + context.counts.builds.get() + ); +} + +#[test] +fn trims_and_separated_pairs_interleaved_lay_out_as_chromium_does_in_two_builds() { + let context = source_han_context(); + let ten = "永".repeat(10); + let text = format!( + "{}{}」{}{}{}」", + "永".repeat(9), + format!("」「{}", "永".repeat(8)).repeat(6), + format!("{ten}」").repeat(3), + "永".repeat(9), + format!("」「{}", "永".repeat(8)).repeat(3), + ); + let outcome = lay(&context, &serif_paragraph(&text), 160.0); + let lines = line_texts(&outcome, &text); + let opened = format!("「{}」", "永".repeat(8)); + let mut expected = vec![format!("{}」", "永".repeat(9))]; + expected.extend(std::iter::repeat_n(opened, 6)); + expected.push("永".repeat(9)); + expected.push(format!("永」{}", "永".repeat(8))); + expected.push(format!("永永」{}", "永".repeat(7))); + expected.push(format!("永永永」{}", "永".repeat(6))); + expected.extend(std::iter::repeat_n( + format!("永永永」「{}", "永".repeat(5)), + 3, + )); + expected.push("永永永」".to_owned()); + assert_eq!(lines, expected); + assert!( + context.counts.builds.get() <= 2, + "built {} times", + context.counts.builds.get() + ); +} + +/// What the spacing plan says a byte carries under some edits is what a +/// build under them pushes there last: the line loop reads the plan +/// instead of building the paragraph again. +#[test] +fn the_spacing_plan_answers_as_a_build_pushes() { + let context = source_han_context(); + let text = "「永」、「永永」。『永』(永)永」永。」』永」「永」永"; + let tree = serif_paragraph(text); + let base = context + .shaped_paragraph( + &tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(500.0), + ParagraphEdits::default(), + &CancelFlag::new(), + ) + .expect("base builds"); + let plan = std::rc::Rc::clone(&base.spacing_plan); + let pairs: Vec = plan + .applied_pair_trims(ParagraphEdits::default()) + .map(|trim| trim.left_byte) + .collect(); + let closers: Vec = text + .char_indices() + .filter(|(_, character)| crate::line_end_trim_eligible(*character)) + .map(|(byte, _)| byte) + .collect(); + assert!( + pairs.len() >= 6 && closers.len() >= 6, + "{pairs:?} {closers:?}" + ); + let chars: Vec = text.char_indices().map(|(byte, _)| byte).collect(); + let mut seed = 0x2545_f491_u32; + let mut next = |bound: usize| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed as usize % bound.max(1) + }; + for _ in 0..64 { + let end_trims: Vec = closers.iter().copied().filter(|_| next(3) == 0).collect(); + let suppressed: Vec = pairs.iter().copied().filter(|_| next(3) == 0).collect(); + let spreads: Vec<(std::ops::Range, f32)> = (0..next(3)) + .map(|_| { + let start = next(chars.len() - 1); + let end = (start + 1 + next(3)).min(chars.len() - 1); + (chars[start]..chars[end], next(7) as f32 - 3.0) + }) + .collect(); + let edits = ParagraphEdits { + end_trims: &end_trims, + suppressed_pair_trims: &suppressed, + split_spreads: &spreads, + unhung_starts: &[], + }; + let built = context + .build_layout( + &tree, + FormattingNodeId(0), + &BuildSpace::new(Some(500.0), None, None), + PercentageImageSizing::Intrinsic, + edits, + &CancelFlag::new(), + ) + .expect("edited build"); + for &byte in &chars { + let pushed = built + .spacing_edits + .letter + .iter() + .rev() + .find(|(range, _)| range.contains(&byte)) + .map_or(0.0, |(_, spacing)| *spacing); + assert_eq!( + plan.letter_spacing_at(text, byte, edits).to_bits(), + pushed.to_bits(), + "byte {byte} under trims {end_trims:?}, suppressed {suppressed:?}, spreads {spreads:?}" + ); + } + } +} + +/// A spread ruby's carrier is priced by the plan, with its start overhang +/// or without (a ruby opening a line drops it), exactly as a build under +/// the same unhung rubies pushes it. +#[test] +fn the_spacing_plan_prices_an_unhung_ruby_as_a_build_pushes() { + let context = source_han_context(); + let mut inline = InlineStyleTable::new(1); + let style = inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 18.0, + 0.0, + ), + ) + .expect("style interns"); + let text_item = |text: &str, annotation: Option<&str>| InlineItem::Text { + text: text.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: annotation.map(|annotation| rito_fragment::RubyAnnotation { + text: annotation.to_owned(), + size_ratio: 0.8, + align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, + }), + }; + let items = vec![ + text_item("永永", None), + text_item("碎片", Some("Fragment")), + text_item("永", None), + text_item("剑", Some("Historia")), + text_item("永永", None), + text_item("人族", Some("Emnetwiht")), + text_item("永", None), + ]; + let text: String = items + .iter() + .map(|item| match item { + InlineItem::Text { text, .. } => text.as_str(), + _ => "", + }) + .collect(); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { items }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let base = context + .shaped_paragraph( + &tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(500.0), + ParagraphEdits::default(), + &CancelFlag::new(), + ) + .expect("base builds"); + let plan = std::rc::Rc::clone(&base.spacing_plan); + let rubies = [1_usize, 3, 5]; + assert!( + rubies.iter().all(|item| plan + .ruby_carrier(*item) + .is_some_and(|c| c.start_overhang > 0.0)), + "every ruby spreads and overhangs" + ); + for mask in 0..8_usize { + let unhung: Vec = rubies + .iter() + .enumerate() + .filter(|(bit, _)| mask & (1 << bit) != 0) + .map(|(_, item)| *item) + .collect(); + let edits = ParagraphEdits { + unhung_starts: &unhung, + ..ParagraphEdits::default() + }; + let built = context + .build_layout( + &tree, + FormattingNodeId(0), + &BuildSpace::new(Some(500.0), None, None), + PercentageImageSizing::Intrinsic, + edits, + &CancelFlag::new(), + ) + .expect("edited build"); + for (byte, _) in text.char_indices() { + let pushed = built + .spacing_edits + .letter + .iter() + .rev() + .find(|(range, _)| range.contains(&byte)) + .map_or(0.0, |(_, spacing)| *spacing); + assert_eq!( + plan.letter_spacing_at(&text, byte, edits).to_bits(), + pushed.to_bits(), + "byte {byte} with rubies {unhung:?} unhung" + ); + } + } +} + +/// A paragraph laid out again in a space it was decided in replays what +/// it decided: nothing is built, broken or decided again, and the lines +/// are the same. +#[test] +fn a_paragraph_laid_out_again_at_its_width_decides_nothing_again() { + let context = source_han_context(); + let segment = format!("{}」", "永".repeat(10)); + let text = segment.repeat(12); + let tree = serif_paragraph(&text); + let first = line_texts(&lay(&context, &tree, 170.0), &text); + let counts = |context: &ParleyInlineContext| { + ( + context.counts.builds.get(), + context.counts.breaks.get(), + context.counts.passes.get(), + ) + }; + let after_first = counts(&context); + assert_eq!(after_first.2, 1, "one pass decided the lines"); + let again = line_texts(&lay(&context, &tree, 170.0), &text); + assert_eq!(again, first); + assert_eq!( + counts(&context), + after_first, + "the second layout replayed the first" + ); +} + +/// The lines (ranges and break reasons) and the letter-spacing pushes the +/// line loop decides for the paragraph at `root` in `space`. +type DriverResult = ( + Vec<(std::ops::Range, String)>, + Vec<(std::ops::Range, f32)>, +); + +fn decided_lines( + context: &ParleyInlineContext, + tree: &FormattingTree, + space: &ConstraintSpace, +) -> (DriverResult, usize) { + let FormattingNodeContent::InlineFlow { items } = &tree.node(FormattingNodeId(0)).content + else { + panic!("an inline flow"); + }; + let mut cursor = 0; + let item_text_ranges: Vec> = items + .iter() + .map(|item| match item { + InlineItem::Text { text, .. } => { + cursor += text.len(); + cursor - text.len()..cursor + } + _ => cursor..cursor, + }) + .collect(); + let cancel = CancelFlag::new(); + let inputs = LineEdgeInputs { + tree, + root: FormattingNodeId(0), + space, + band: space.float_band, + item_text_ranges: &item_text_ranges, + cancel: &cancel, + }; + let result = |decided: &DecidedParagraph| -> DriverResult { + ( + decided + .layout + .lines() + .map(|line| (line.text_range(), format!("{:?}", line.break_reason()))) + .collect(), + decided.spacing_edits.letter.clone(), + ) + }; + let base_pushes = context + .shaped_paragraph( + tree, + FormattingNodeId(0), + space, + ParagraphEdits::default(), + &cancel, + ) + .expect("base builds") + .spacing_edits + .letter + .len(); + let decided = context + .decide_line_edges(&inputs, &mut WidthBounds::new(space.inline_size)) + .expect("line edges decide"); + (result(&decided), base_pushes) +} + +/// The paragraph `items` (one text item each, all in the serif) laid out +/// at `width`. +fn lay_text_items( + context: &ParleyInlineContext, + items: &[&str], + padding_right: &[f32], + width: f64, +) -> LayoutOutcome { + let mut inline = InlineStyleTable::new(items.len()); + let inline_items: Vec = items + .iter() + .enumerate() + .map(|(index, text)| { + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + let padding = padding_right.get(index).copied().unwrap_or(0.0); + style.fragment.padding.right = rito_style_contract::NonNegativeLengthPercentage::new( + LengthPercentage::Length(rito_style_contract::CssPx::new(padding).expect("finite")), + ); + let style = inline.intern_for_node(index, style).expect("style interns"); + let text = InlineItem::Text { + text: (*text).to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }; + if padding == 0.0 { + vec![text] + } else { + vec![text, InlineItem::CloseTag { style }] + } + }) + .collect::>() + .concat(); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: inline_items, + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + lay(context, &tree, width) +} + +/// A closer the line overflows by half a glyph extends the line inside +/// whichever text item it is in, wherever the line crosses an element +/// boundary and whether or not it ends its item (Chromium 156 at 172px: +/// each of these is one line ending in a half-width `)` but the third, +/// which sends only what follows the closer down). +#[test] +fn a_closer_extends_its_line_across_element_boundaries() { + let context = source_han_context(); + let nine = "永".repeat(9); + let body = format!("{nine})"); + let seven = "永".repeat(7); + let cases: [(Vec, Vec); 5] = [ + (vec!["①".into(), body.clone()], vec![format!("①{body}")]), + (vec![format!("①{body}")], vec![format!("①{body}")]), + ( + vec!["①".into(), format!("{body}永永")], + vec![format!("①{body}"), "永永".into()], + ), + ( + vec!["永永".into(), "永".into(), format!("{seven})永永")], + vec![format!("永永永{seven})"), "永永".into()], + ), + ( + vec!["永永".into(), "永".into(), format!("{seven})")], + vec![format!("永永永{seven})")], + ), + ]; + for (items, expected) in cases { + let items: Vec<&str> = items.iter().map(String::as_str).collect(); + let outcome = lay_text_items(&context, &items, &[], 172.0); + assert_eq!(line_texts(&outcome, &items.concat()), expected, "{items:?}"); + } +} + +/// The line-end extension fits a line within one LayoutUnit of its width, +/// as its other lines fit: Chromium 156 trims the closer at 1/64px over +/// and not at 2/64px. +#[test] +fn a_line_end_trim_fits_within_one_layout_unit() { + let context = source_han_context(); + let text = format!("{}」永永", "永".repeat(10)); + let lines = |overflow: f64| { + line_texts( + &lay(&context, &serif_paragraph(&text), 168.0 - overflow / 64.0), + &text, + ) + }; + assert_eq!( + lines(1.0), + [format!("{}」", "永".repeat(10)), "永永".into()] + ); + assert_eq!( + lines(2.0), + ["永".repeat(9), "永」永永".into()], + "two LayoutUnits over is not a fit" + ); +} + +/// A closer ending a box with end padding extends the line only when the +/// padding fits after its trimmed glyph, and keeps the padding beside it: +/// Chromium 156 sends the closer down at 170px (160 + 8 + 4 > 170) and +/// keeps it at 173px, its glyph box ending at 168px. +#[test] +fn a_trimmed_closer_keeps_the_padding_of_the_box_it_ends() { + let context = source_han_context(); + let ten = "永".repeat(10); + let items = [format!("{ten}」"), "永永".to_owned()]; + let items: Vec<&str> = items.iter().map(String::as_str).collect(); + let text = items.concat(); + let narrow = lay_text_items(&context, &items, &[4.0], 170.0); + assert_eq!( + line_texts(&narrow, &text), + ["永".repeat(9), "永」永永".into()] + ); + let wide = lay_text_items(&context, &items, &[4.0], 173.0); + assert_eq!( + line_texts(&wide, &text), + [format!("{ten}」"), "永永".into()] + ); + let Fragment::Box(root) = &wide.fragments.root else { + panic!("inline outcome root is a box fragment"); + }; + let Fragment::Line(first) = &root.children[0] else { + panic!("inline children are line fragments"); + }; + let closer_right = first + .children + .iter() + .filter_map(|run| match run { + Fragment::Text(run) => Some(run.rect.x + run.rect.width), + _ => None, + }) + .fold(0.0_f64, f64::max); + assert_eq!(closer_right, 168.0, "the trimmed closer's glyph box"); +} + +/// A ruby base split across lines spreads each segment to its allocated +/// annotation words decided line by line, as the whole-paragraph fixpoint +/// the line loop replaced decided them (its lines and pushes at every +/// width, pinned when it was retired). +#[test] +fn split_ruby_segments_spread_as_pinned() { + let context = source_han_context(); + let mut inline = InlineStyleTable::new(1); + let style = inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ), + ) + .expect("style interns"); + let text_item = |text: &str, annotation: Option<&str>| InlineItem::Text { + text: text.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: annotation.map(|annotation| rito_fragment::RubyAnnotation { + text: annotation.to_owned(), + size_ratio: 0.5, + align: rito_style_contract::RubyAlign::SpaceAround, + continues_ruby: false, + }), + }; + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![ + text_item("永永永永永", None), + text_item("黄金妖精", Some("Leprechaun Leprechaun Lep")), + text_item("永永永永永永永永", None), + ], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let mut spread = 0; + let pinned = include_str!("fixtures/ruby_split_spreads.txt").lines(); + for (width, pinned) in (96..160).zip(pinned) { + let (decided, base_pushes) = decided_lines( + &context, + &tree, + &ConstraintSpace::continuous(f64::from(width)), + ); + assert_eq!(snapshot_line(width, &decided), pinned); + if decided.1.len() > base_pushes { + spread += 1; + } + } + assert!(spread > 0, "some width splits the base and spreads it"); +} + +/// Lines beside a float band break at its narrower width with their trims +/// and pairs decided against it, as the whole-paragraph fixpoint the line +/// loop replaced decided them (pinned when it was retired). +#[test] +fn line_edges_beside_a_float_band_are_decided_as_pinned() { + let context = source_han_context(); + let text = format!( + "{}{}」{}", + "永".repeat(9), + format!("」「{}", "永".repeat(8)).repeat(4), + format!("{}」", "永".repeat(10)).repeat(4), + ); + let tree = serif_paragraph(&text); + let mut decided = 0; + let mut pinned = include_str!("fixtures/float_band_line_edges.txt").lines(); + for width in 150..200 { + let space = ConstraintSpace { + float_band: Some(rito_fragment::FloatBand { + left_inset: 24.0, + right_inset: 0.0, + bottom: 70.0, + }), + ..ConstraintSpace::continuous(f64::from(width)) + }; + let (lines, base_pushes) = decided_lines(&context, &tree, &space); + assert_eq!(Some(snapshot_line(width, &lines).as_str()), pinned.next()); + if lines.1.len() != base_pushes { + decided += 1; + } + } + assert!(decided > 0, "some width decides a line edge"); +} + +/// A cancelled layout stops while it decides its line edges. +#[test] +fn a_cancelled_layout_stops_deciding() { + let context = source_han_context(); + let text = format!("{}」", "永".repeat(10)).repeat(8); + let cancel = CancelFlag::new(); + cancel.cancel(); + let outcome = context.layout( + &serif_paragraph(&text), + FormattingNodeId(0), + &ConstraintSpace::continuous(170.0), + None, + &cancel, + ); + assert!(matches!(outcome, Err(LayoutError::Cancelled))); +} + +/// The inline sizes a layout without its text reports it holds for lay the +/// paragraph out exactly alike, decisions and all. +#[test] +fn the_sizes_a_decided_paragraph_holds_for_lay_it_out_alike() { + let text = format!( + "{}{}", + format!("{}」", "永".repeat(10)).repeat(6), + format!("」「{}", "永".repeat(8)).repeat(4), + ); + let tree = serif_paragraph(&text); + let omitted = |width: f64| ConstraintSpace { + line_text: LineText::Omitted, + ..ConstraintSpace::continuous(width) + }; + let laid = |width: f64| { + source_han_context() + .layout( + &tree, + FormattingNodeId(0), + &omitted(width), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds") + }; + // The lines; the root box spans whatever width it is laid out at. + let lines = |outcome: &LayoutOutcome| match &outcome.fragments.root { + Fragment::Box(root) => root.children.clone(), + _ => panic!("the root is a box"), + }; + let at = laid(170.0); + let (lo, hi) = at + .inline_size_range + .expect("the decided layout knows its range"); + assert!(lo <= 170.0 && 170.0 < hi, "{lo}..{hi}"); + for width in [lo, (lo + 170.0) / 2.0, (170.0 + hi) / 2.0, hi - 1e-6] { + assert_eq!( + lines(&laid(width)), + lines(&at), + "at {width}px in {lo}..{hi}" + ); + } +} + +/// One width's lines and letter-spacing pushes as one fixture line. +fn snapshot_line(width: i32, (lines, pushes): &DriverResult) -> String { + let lines: Vec = lines + .iter() + .map(|(range, reason)| format!("{}..{} {reason}", range.start, range.end)) + .collect(); + let pushes: Vec = pushes + .iter() + .map(|(range, spacing)| format!("{}..{} {spacing:?}", range.start, range.end)) + .collect(); + format!("{width} | {} | {}", lines.join(", "), pushes.join(", ")) +} diff --git a/crates/rito-inline/src/tests/line_items.rs b/crates/rito-inline/src/tests/line_items.rs index 4282bf24c..6479bfe19 100644 --- a/crates/rito-inline/src/tests/line_items.rs +++ b/crates/rito-inline/src/tests/line_items.rs @@ -46,7 +46,7 @@ fn mixed_tree() -> FormattingTree { fit_contain: false, object_fit: rito_style_contract::ObjectFit::Fill, viewport: None, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, baseline_shift_px: 0.0, }, text(" then שלום עולם and בוקר טוב read right to left, "), @@ -83,6 +83,7 @@ fn a_line_splits_alike_with_and_without_its_glyphs() { end_trims: &[], suppressed_pair_trims: &[], split_spreads: &[], + unhung_starts: &[], }; let mut lines_checked = 0; let mut bytes_checked = 0; diff --git a/crates/rito-inline/src/tests/mod.rs b/crates/rito-inline/src/tests/mod.rs index 43184c4e7..9e2832c18 100644 --- a/crates/rito-inline/src/tests/mod.rs +++ b/crates/rito-inline/src/tests/mod.rs @@ -18,11 +18,15 @@ use std::sync::Arc; const FONT_SIZE: f32 = 16.0; mod atoms_and_grid; +mod box_edges; +mod break_rules; mod breaks_and_spacing; mod clusters; mod envelopes_and_ruby; mod fonts; +mod line_decisions; mod line_items; +mod parley_breaker; mod punctuation_and_contract; /// A publication `@font-face` binding of `family` to `bytes`, upright at @@ -131,12 +135,15 @@ fn tinos_style(indent_px: f32) -> InlineFormattingStyle { each_line: false, }, ruby_align: RubyAlign::SpaceAround, + text_emphasis: None, language: None, }, bidi: InlineBidi { direction: Direction::LeftToRight, unicode_bidi: UnicodeBidi::Normal, writing_mode: WritingMode::HorizontalTopToBottom, + text_orientation: rito_style_contract::TextOrientation::Mixed, + text_combine_upright: rito_style_contract::TextCombineUpright::None, }, fragment: InlineFragmentStyle { margin: sides(LengthPercentageOrAuto::Value(LengthPercentage::Length( diff --git a/crates/rito-inline/src/tests/parley_breaker.rs b/crates/rito-inline/src/tests/parley_breaker.rs new file mode 100644 index 000000000..510bf118d --- /dev/null +++ b/crates/rito-inline/src/tests/parley_breaker.rs @@ -0,0 +1,400 @@ +//! The vendored Parley line breaker on layouts built straight through its +//! builder: what it measures a line at and where it must break. + +use super::*; + +/// A Source Han Serif paragraph (its `liga` joins `fi`) whose byte ranges +/// take the given font sizes. +fn sized_layout( + context: &ParleyInlineContext, + text: &str, + sizes: &[(std::ops::Range, f32)], +) -> parley::Layout<[u8; 4]> { + let mut fonts = context.fonts.borrow_mut(); + let mut layouts = context.layouts.borrow_mut(); + let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, text, 1.0, true)); + for (range, size) in sizes { + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + *size, + 0.0, + ); + push_item_styles(&mut builder, &context.catalog, &style, text, range.clone()); + } + builder.build(text).0 +} + +fn source_han_context() -> ParleyInlineContext { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + ParleyInlineContext::new(vec![source_han]).expect("context builds") +} + +/// The advances of every cluster starting in `range`, summed. +fn cluster_advance_sum(layout: &parley::Layout<[u8; 4]>, range: std::ops::Range) -> f32 { + let mut sum = 0.0; + let mut cluster = parley::layout::Cluster::from_byte_index(layout, range.start); + while let Some(current) = cluster { + if current.text_range().start >= range.end { + break; + } + sum += current.advance(); + cluster = current.next_logical(); + } + sum +} + +/// A ligature's components are measured with the clusters of their own +/// run: a run that does not start the paragraph read its look-ahead +/// through an absolute cluster index, so the `fi` of the second run took +/// the advance of the `fi` at the paragraph's end instead of its own. +#[test] +fn a_ligature_is_measured_by_its_own_clusters_in_any_run() { + let context = source_han_context(); + let text = "xxxxxxxxxxfixxxxxxxxfi"; + let mut layout = sized_layout( + &context, + text, + &[(0..10, 10.0), (10..12, 12.0), (12..22, 40.0)], + ); + layout.break_all_lines(None); + let ligature = parley::layout::Cluster::from_byte_index(&layout, 10).expect("cluster at 10"); + assert!( + ligature.is_ligature_start(), + "the second run's fi is a ligature" + ); + let line = layout.get(0).expect("one line"); + let measured = line.metrics().advance; + let advances = cluster_advance_sum(&layout, 0..text.len()); + assert!( + (measured - advances).abs() < 1e-3, + "the line measured {measured}, its clusters advance {advances}" + ); +} + +/// A forced break is taken before any soft wrap opportunity: an override +/// that marks the newline itself breakable (`line-break: anywhere` marks +/// every character; Chromium's tailoring marks the character after a +/// space) took the soft branch and lost the forced break. +#[test] +fn a_newline_breaks_the_line_whatever_marks_it_breakable() { + fn anywhere(_: parley::LineBreakContext) -> Option { + Some(true) + } + let context = source_han_context(); + for (text, overrides) in [ + ("ab\ncd", &anywhere as &parley::LineBreakOverrideFn), + ("a \nb", parley::CHROMIUM_LINE_BREAK_OVERRIDE), + ] { + let mut fonts = context.fonts.borrow_mut(); + let mut layouts = context.layouts.borrow_mut(); + let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, text, 1.0, true)); + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + push_item_styles(&mut builder, &context.catalog, &style, text, 0..text.len()); + builder.set_line_break_override(overrides); + let mut layout = builder.build(text).0; + layout.break_all_lines(Some(1000.0)); + assert_eq!(layout.len(), 2, "{text:?} breaks at its newline"); + assert_eq!( + layout.get(0).expect("first line").break_reason(), + parley::layout::BreakReason::Explicit, + "{text:?}" + ); + } +} + +/// The same through the inline layout: `line-break: anywhere` and a +/// preserved space before a `
` keep the forced break. +#[test] +fn a_forced_break_survives_line_break_anywhere_and_a_preserved_space() { + let context = source_han_context(); + let anywhere = |style: &mut InlineFormattingStyle| { + style.text_flow.line_break = LineBreak::Anywhere; + }; + let preserved = |style: &mut InlineFormattingStyle| { + style.text_flow.white_space_collapse = WhiteSpaceCollapse::Preserve; + }; + for (text, adjust) in [ + ( + "永永\n永永", + &anywhere as &dyn Fn(&mut InlineFormattingStyle), + ), + ("a \nb", &preserved), + ] { + let mut inline = InlineStyleTable::new(1); + let mut style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + adjust(&mut style); + let style = inline.intern_for_node(0, style).expect("style interns"); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.to_owned(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(1000.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let lines = line_texts(&outcome, text); + assert_eq!(lines.len(), 2, "{text:?}: {lines:?}"); + } +} + +/// A cluster finds its text in a shaping run of any length: its offset +/// into the run was kept in 16 bits, so past 64 KiB of one run (a whole +/// volume written as one block of `
`-separated lines in one style) +/// clusters pointed at the wrong text and the lines could not be laid +/// out (book 048 at a 500px column). +#[test] +fn a_shaping_run_longer_than_64_kib_keeps_its_clusters_text() { + let context = source_han_context(); + let text = "永".repeat(30_000); + let mut layout = sized_layout(&context, &text, &[(0..text.len(), 16.0)]); + layout.break_all_lines(Some(500.0)); + for byte in [0, 65_535, 70_002, text.len() - 3] { + let cluster = parley::layout::Cluster::from_byte_index(&layout, byte).expect("cluster"); + assert_eq!(cluster.text_range(), byte..byte + 3, "cluster at {byte}"); + } + let mut inline = InlineStyleTable::new(1); + let style = inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ), + ) + .expect("style interns"); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.clone(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(500.0), + None, + &CancelFlag::new(), + ) + .expect("a paragraph longer than 64 KiB lays out"); + let lines = line_texts(&outcome, &text); + assert_eq!(lines.concat(), text); +} + +/// A 16px Source Han Serif paragraph carrying inline boxes of the given +/// kinds and widths (zero height) at the given byte indices. +fn tagged_layout( + context: &ParleyInlineContext, + text: &str, + boxes: &[(usize, InlineBoxKind, f32)], +) -> parley::Layout<[u8; 4]> { + let mut fonts = context.fonts.borrow_mut(); + let mut layouts = context.layouts.borrow_mut(); + let mut builder = SpacingBuilder::new(layouts.ranged_builder(&mut fonts, text, 1.0, true)); + let style = plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic(GenericFontFamily::Serif)]) + .expect("family list"), + 16.0, + 0.0, + ); + push_item_styles(&mut builder, &context.catalog, &style, text, 0..text.len()); + for (id, (index, kind, width)) in boxes.iter().enumerate() { + builder.push_inline_box(InlineBox { + id: id as u64, + kind: *kind, + index: *index, + width: *width, + height: 0.0, + }); + } + builder.build(text).0 +} + +/// Each line's text range and the x its first glyph run starts at. +fn line_starts(layout: &parley::Layout<[u8; 4]>) -> Vec<(std::ops::Range, f32)> { + layout + .lines() + .map(|line| { + let x = line + .items() + .find_map(|item| match item { + PositionedLayoutItem::GlyphRun(run) => Some(run.offset()), + PositionedLayoutItem::InlineBox(_) => None, + }) + .unwrap_or(0.0); + (line.text_range(), x) + }) + .collect() +} + +/// An open tag offers no break of its own: the break the text allows at +/// its index is taken before it, so it opens the next line with the text +/// it opens, and the character before it is measured without it. +#[test] +fn an_open_tag_opens_the_next_line_with_what_it_opens() { + let context = source_han_context(); + let text = "永永永永永永永永永永永永"; + for width in [162.0_f32, 165.0] { + let mut layout = tagged_layout(&context, text, &[(30, InlineBoxKind::OpenTag, 4.0)]); + layout.break_all_lines(Some(width)); + assert_eq!( + line_starts(&layout), + [(0..30, 0.0), (30..36, 4.0)], + "at {width}px" + ); + } +} + +/// Where the text allows no break, an open tag does not make one: it stays +/// inside the word it splits. +#[test] +fn an_open_tag_inside_a_word_is_no_break() { + let context = source_han_context(); + let text = "aaaaaabbbb cc"; + let mut layout = tagged_layout(&context, text, &[(6, InlineBoxKind::OpenTag, 4.0)]); + layout.break_all_lines(Some(60.0)); + let ranges: Vec<_> = line_starts(&layout) + .into_iter() + .map(|(range, _)| range) + .collect(); + assert_eq!(ranges.first(), Some(&(0..11)), "{ranges:?}"); +} + +/// A close tag stays with the text it closes: when it overflows, the line +/// breaks at the last opportunity before that text. +#[test] +fn a_close_tag_that_overflows_takes_its_text_down() { + let context = source_han_context(); + let text = "永永永永永永永永永永永永"; + let mut layout = tagged_layout(&context, text, &[(30, InlineBoxKind::CloseTag, 4.0)]); + layout.break_all_lines(Some(162.0)); + assert_eq!(line_starts(&layout), [(0..27, 0.0), (27..36, 0.0)]); + let mut layout = tagged_layout(&context, text, &[(30, InlineBoxKind::CloseTag, 4.0)]); + layout.break_all_lines(Some(165.0)); + assert_eq!(line_starts(&layout), [(0..30, 0.0), (30..36, 0.0)]); +} + +/// A line forced to end before an open tag's index sends the tag down with +/// what it opens; a close tag at that index stays on the line. +#[test] +fn a_forced_line_end_keeps_close_tags_and_sends_open_tags_down() { + let context = source_han_context(); + let text = "永永永永永永"; + let mut layout = tagged_layout( + &context, + text, + &[ + (9, InlineBoxKind::CloseTag, 3.0), + (9, InlineBoxKind::OpenTag, 4.0), + ], + ); + { + let mut breaker = layout.break_lines(); + breaker.state_mut().set_layout_max_advance(1000.0); + breaker.state_mut().set_line_max_advance(1000.0); + breaker.break_next_to(9).expect("a forced line"); + breaker.state_mut().set_line_max_advance(1000.0); + while breaker.break_next().is_some() {} + breaker.finish(); + } + let lines = line_starts(&layout); + assert_eq!(lines, [(0..9, 0.0), (9..18, 4.0)]); + assert_eq!(layout.get(0).expect("first line").metrics().advance, 51.0); +} + +/// An open tag's width joins the min-content segment of the text after it, +/// a close tag's the segment of the text before it. +#[test] +fn tags_join_the_min_content_segment_of_the_text_they_edge() { + let context = source_han_context(); + let layout = tagged_layout( + &context, + "永永永", + &[ + (3, InlineBoxKind::OpenTag, 4.0), + (6, InlineBoxKind::CloseTag, 3.0), + ], + ); + let widths = layout.calculate_content_widths(); + assert_eq!(widths.min, 23.0); + assert_eq!(widths.max, 55.0); +} + +/// A cluster's line-end reshape delta is its own on every line: Tinos +/// kerns "A" against the space after it, never "t", so the "t" ending a +/// later line (where the line's runs start mid-paragraph) reads zero and +/// the "A" reads the kern it loses alone (`kern(A, space)` = −0.89px at +/// 16px, measured on Chromium: "A " is 14.671875 wide, "A" 11.5625). +#[test] +fn a_line_end_delta_belongs_to_its_own_cluster_on_any_line() { + let context = ParleyInlineContext::new(vec![tinos_bytes()]).expect("context builds"); + let text = "Dumbledore pointed toward a run-down stone outhouse where the Weasleys kept their broomsticks. A little puzzled, Harry followed Dumbledore through the creaking door into a space a little smaller than the average cupboard. Dumbledore illuminated the tip of his wand, so that it glowed like a torch."; + let mut layout = sized_layout(&context, text, &[(0..text.len(), 16.0)]); + layout.break_all_lines(Some(640.0)); + assert!(layout.len() >= 3, "the paragraph wraps"); + let t = text.find("it glowed").expect("t") + 1; + let a = text.find("A little").expect("A"); + let delta = |byte: usize| { + parley::layout::Cluster::from_byte_index(&layout, byte) + .expect("cluster") + .line_end_delta() + }; + assert_eq!(delta(t), 0.0, "t keeps its advance alone"); + assert!( + (f64::from(delta(a)) - (15.5625 - 14.671875)).abs() < 1.0 / 64.0, + "A loses its kern with the space: {}", + delta(a) + ); +} diff --git a/crates/rito-inline/src/tests/punctuation_and_contract.rs b/crates/rito-inline/src/tests/punctuation_and_contract.rs index d65e76ee5..0ee4e9513 100644 --- a/crates/rito-inline/src/tests/punctuation_and_contract.rs +++ b/crates/rito-inline/src/tests/punctuation_and_contract.rs @@ -13,8 +13,8 @@ fn the_badge_line_replica_matches_the_truth_comma_position() { use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleTable, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, Overflow, PageBreak, - PhysicalSides, Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, Overflow, PageBreak, PhysicalSides, Position, + PreferredSize, }; let source_han = std::fs::read(concat!( env!("CARGO_MANIFEST_DIR"), @@ -58,6 +58,9 @@ fn the_badge_line_replica_matches_the_truth_comma_position() { bottom: zero_padding, left: zero_padding, }, + border_widths: rito_style_contract::PhysicalSides::uniform( + rito_style_contract::NonNegativeCssPx::ZERO, + ), box_sizing: rito_style_contract::BoxSizing::ContentBox, justify_content: JustifyContent::Normal, align_items: AlignItems::Normal, @@ -66,8 +69,11 @@ fn the_badge_line_replica_matches_the_truth_comma_position() { width: PreferredSize::Auto, height: PreferredSize::Auto, max_width: MaximumSize::None, - min_height: MinimumHeight::Auto, - max_height: MaximumHeight::None, + min_height: MinimumSize::Auto, + min_width: MinimumSize::Auto, + writing_mode: rito_style_contract::WritingMode::HorizontalTopToBottom, + direction: rito_style_contract::Direction::LeftToRight, + max_height: MaximumSize::None, clear: Clear::None, float: Float::None, overflow: Overflow::Visible, @@ -105,7 +111,7 @@ fn the_badge_line_replica_matches_the_truth_comma_position() { layout_style: image_layout, viewport: None, baseline_shift_px: 0.0, - align_top: false, + align: rito_fragment::AtomAlign::Baseline, fit_contain: false, object_fit: rito_style_contract::ObjectFit::Fill, }, @@ -287,6 +293,37 @@ fn cjk_punctuation_pairs_trim_half_an_em() { (middle - plain).abs() < 0.1, "middle dot before a close must not trim: {middle}" ); + // The CJK double prime quotes are fullwidth brackets (measured in + // pinned Chromium: `り、〝チ` gives 〝 8px, `グ〟、の` gives 〟 8px). + let comma_prime = shape_width("春日、〝春日"); + assert!( + (comma_prime - (plain - 8.0)).abs() < 0.1, + "an opening double prime after a comma trims: {comma_prime}" + ); + let prime_comma = shape_width("春日〟、春日"); + assert!( + (prime_comma - (plain - 8.0)).abs() < 0.1, + "a closing double prime before a comma trims: {prime_comma}" + ); + let primes = shape_width("春〝日〟春日"); + assert!( + (primes - plain).abs() < 0.1, + "double primes against ideographs stay full: {primes}" + ); + // A narrow opener, a narrow closer and the Latin middle dot make + // their fullwidth neighbour trim (measured: `(「` = 5.33+8, + // `」)` = 8+5.33, `·「` = 5.33+8). + for (trimming, full) in [ + ("春(「日日", "春(日日日"), + ("春」)日日", "春日)日日"), + ("春·「日日", "春·日日日"), + ] { + let delta = shape_width(trimming) - shape_width(full); + assert!( + (delta + 8.0).abs() < 0.1, + "{trimming} trims half an em against {full}: {delta}" + ); + } } /// Consecutive forced breaks keep their empty line: Blink lays @@ -889,6 +926,19 @@ fn empty_font_registration_fails_closed() { assert!(ParleyInlineContext::new(vec![vec![0_u8; 4]]).is_err()); } +/// A no-break space is a word separator a justified line expands after, +/// as Blink treats it as a space (`unicode::TreatAsSpace`): a numbered +/// item "2.\u{A0}Particular care" spreads the gap after its number with +/// the others. +#[test] +fn a_no_break_space_takes_a_justification_share() { + let text = "2.\u{A0}Particular care"; + let plan = line_justify_plan(text, 0..text.len(), 4.0, &[], &[], &[]).expect("plan builds"); + assert_eq!(plan.share, 2.0, "two separators share the slack"); + assert_eq!(plan.count_at(4), 1, "the boundary after the no-break space"); + assert_eq!(plan.count_at(15), 1, "the boundary after the space"); +} + #[test] fn an_atomic_inline_expands_before_but_defers_after() { // Measured against a Chromium badge-line justify map (2026-08-13): @@ -900,7 +950,7 @@ fn an_atomic_inline_expands_before_but_defers_after() { // unchanged; only the comma's own x shifts one share left. let text = "中中,中"; let atoms = vec![6usize]; - let plan = line_justify_plan(text, 0..text.len(), 5.0, &[], &atoms).expect("plan builds"); + let plan = line_justify_plan(text, 0..text.len(), 5.0, &[], &atoms, &[]).expect("plan builds"); assert_eq!( plan.share, 1.25, "4 opportunities: deferral keeps the total" @@ -922,10 +972,88 @@ fn an_atomic_inline_expands_before_but_defers_after() { "one share before the atom plus its own left boundary" ); let control = - line_justify_plan(text, 0..text.len(), 5.0, &[], &[]).expect("control plan builds"); + line_justify_plan(text, 0..text.len(), 5.0, &[], &[], &[]).expect("control plan builds"); assert_eq!( control.share, 5.0 / 3.0, "without the atom: 3 opportunities" ); } + +/// A closer a forced break follows is never trimmed at a line's end: the +/// break ends the line there whatever the closer's width, so the line can +/// never end exactly after the trimmed closer (pinned Chromium wraps +/// `…十」
` alike: 十」 opens the next line). The paragraph is built once +/// for it, not again with a trim the line could only reject: a chapter +/// laid out as one `
`-broken paragraph shaped itself again for every +/// such dialogue line. +#[test] +fn a_closer_before_a_forced_break_is_not_built_trimmed() { + let source_han = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf" + )) + .expect("pinned serif reads"); + let context = ParleyInlineContext::new(vec![source_han]).expect("context builds"); + let mut inline = InlineStyleTable::new(1); + let style = inline + .intern_for_node( + 0, + plain_paragraph_style( + FontFamilies::new(vec![FontFamily::Generic( + rito_style_contract::GenericFontFamily::Serif, + )]) + .expect("family list"), + 16.0, + 0.0, + ), + ) + .expect("style interns"); + // Ten ideographs fill 160px; the closer needs 176 full, 168 trimmed. + let text = format!("{}」\n永永", "永".repeat(10)); + let tree = FormattingTree::with_styles( + vec![FormattingNode { + style: rito_style_contract::LayoutStyleId::from_raw(0), + content: FormattingNodeContent::InlineFlow { + items: vec![InlineItem::Text { + text: text.clone(), + style, + baseline_shift_px: 0.0, + ruby_annotation: None, + }], + }, + children: Vec::new(), + }], + FormattingNodeId(0), + rito_fragment::FormattingTreeStyles { + layout: LayoutStyleTable::new(0), + inline, + }, + ) + .expect("inline tree builds"); + let outcome = context + .layout( + &tree, + FormattingNodeId(0), + &ConstraintSpace::continuous(170.0), + None, + &CancelFlag::new(), + ) + .expect("layout succeeds"); + let lines = line_texts(&outcome, &text); + assert_eq!( + lines[0], + "永".repeat(9), + "the closer is not trimmed onto the line" + ); + assert_eq!( + lines[1].trim_end(), + "永」", + "it opens the next line full-width" + ); + assert_eq!( + context.shaped.borrow().kept_count(), + 1, + "the paragraph is built once, untrimmed" + ); +} diff --git a/crates/rito-inline/src/vertical.rs b/crates/rito-inline/src/vertical.rs new file mode 100644 index 000000000..97c341815 --- /dev/null +++ b/crates/rito-inline/src/vertical.rs @@ -0,0 +1,318 @@ +//! How the clusters of a vertical line stand. Under `text-orientation: +//! mixed` a character whose Unicode vertical orientation is anything but +//! rotated stands upright and the rest lie sideways (Blink's +//! `Character::IsUprightInMixedVertical`); `upright` and `sideways` set +//! every character one way. +//! +//! A run's text box hangs from the line's central baseline by its primary +//! font's central ascent, and its text is drawn from the primary font's +//! alphabetic baseline, that font's ascent in from the box's line-over +//! edge. A sideways glyph sits on that baseline. An upright glyph centres +//! on the line its own font's central baseline makes there: its font's +//! ascent less half its height back across from the alphabetic baseline. +//! It hangs from its vertical origin: its horizontal origin half its +//! horizontal advance across from that centre and the face's vertical +//! origin down the line, from the VORG table, else the glyph's top plus its +//! `vmtx` top side bearing, else the face's ascent (Blink's +//! `OpenTypeVerticalData::GetVerticalTranslationsForGlyphs`). Chromium's +//! DOM column and a canvas drawing the glyphs there match pixel for pixel, +//! whichever font is primary. +//! +//! A `text-combine-upright: all` item is one unit an em down the line +//! (Blink's `LayoutTextCombine`): a box as wide across the line as its +//! primary font's whole-pixel height, over the run's central baseline as +//! that font's text box is, in which the item's text sets as one +//! horizontal string centred across. A string wider than 1.1em takes the +//! face's half-, third- then quarter-width forms, the first that fits, and +//! failing those is scaled to 1.1em about its start, which then sits half +//! the box's spare width in. Its alphabetic baseline lies the font's +//! whole-pixel ascent less half its internal leading below the unit's top. +//! Measured against Chromium's DOM at 16, 17.3 and 18px, after none to +//! three ems of text: a canvas drawing the string there matches pixel for +//! pixel, in the face's own forms, its half-width forms and scaled. + +use std::rc::Rc; + +use icu_properties::props::{GeneralCategory, VerticalOrientation}; +use icu_properties::CodePointMapData; +use rito_fragment::{ClusterOrientation, ClusterPosition, CombinedForms}; +use rito_style_contract::{ + InlineFormattingStyle, TextCombineUpright, TextOrientation, WritingMode, +}; +use skrifa::instance::{LocationRef, Size}; +use skrifa::raw::TableProvider as _; +use skrifa::MetadataProvider as _; + +use crate::*; + +/// An upright glyph's cache key: its face, its character and the size's +/// bits. +pub(crate) type UprightKey = (rito_fragment::FontFaceId, char, u64); + +/// Where an upright glyph lies from its font's alphabetic baseline: its +/// centre line across, then half its advance and its vertical origin. +#[derive(Clone, Copy, Debug)] +pub(crate) struct UprightGlyph { + center: f64, + half_advance: f64, + vertical_origin: f64, +} + +/// A combined upright unit's cache key: its text and font. +pub(crate) type CombinedKey = (String, rito_style_contract::FontStyle); + +/// How a combined upright unit sets its string (see the module docs): +/// where the string starts across from the run's central baseline and +/// its baseline down from the unit's place, its forms and its scale. +#[derive(Clone, Copy, Debug)] +pub(crate) struct CombinedSetting { + across: f64, + down: f64, + scale_x: f64, + forms: CombinedForms, +} + +/// Whether text in `style` sets as combined upright units: a vertical +/// line's `text-combine-upright: all` text. +pub(crate) fn combines_upright(style: &InlineFormattingStyle) -> bool { + style.bidi.text_combine_upright == TextCombineUpright::All + && style.bidi.writing_mode != WritingMode::HorizontalTopToBottom +} + +/// Whether `character` in `style` shapes top to bottom: upright in a +/// vertical line and not part of a combined upright unit. +pub(crate) fn shapes_top_to_bottom(style: &InlineFormattingStyle, character: char) -> bool { + style.bidi.writing_mode != WritingMode::HorizontalTopToBottom + && !combines_upright(style) + && stands_upright(style.bidi.text_orientation, character) +} + +/// The advance a combined upright unit takes down the line: an em on the +/// 1/64 grid, rounded (`ComputedStyle::ComputedFontSizeAsFixed`). +pub(crate) fn combined_advance(style: &InlineFormattingStyle) -> f32 { + (style.font.size.get() * 64.0).round() / 64.0 +} + +impl ParleyInlineContext { + /// How each of `clusters` stands in a vertical line laid out in + /// `style`, `text` being the text their bytes index. + pub(crate) fn cluster_orientations( + &self, + style: &InlineFormattingStyle, + clusters: &[ClusterPosition], + text: &str, + ) -> Rc<[ClusterOrientation]> { + let size = f64::from(style.font.size.get()); + // The primary font's alphabetic baseline, across from the run's + // central baseline: its ascent less its central ascent. + let (ascent, descent) = self + .primary_face(style) + .map_or((0.0, 0.0), |face| face.whole_pixel_extent(size)); + if combines_upright(style) { + // The fragment is one item's text, so one unit. + let start = clusters.first().map_or(0, |cluster| cluster.byte as usize); + let end = clusters.last().map_or(start, |last| { + let last = last.byte as usize; + text.get(last..) + .and_then(|rest| rest.chars().next()) + .map_or(last, |character| last + character.len_utf8()) + }); + let setting = self.combined_setting( + style, + text.get(start..end).unwrap_or_default(), + ascent, + descent, + ); + return (0..clusters.len()) + .map(|index| ClusterOrientation::Combined { + across: setting.across, + down: setting.down, + scale_x: setting.scale_x, + forms: setting.forms, + continues: index > 0, + }) + .collect(); + } + let baseline = ascent - LineBaseline::Central.split(ascent, descent).0; + clusters + .iter() + .map(|cluster| { + let character = text + .get(cluster.byte as usize..) + .and_then(|rest| rest.chars().next()) + .unwrap_or(' '); + if !stands_upright(style.bidi.text_orientation, character) { + return ClusterOrientation::Sideways { baseline }; + } + let key = (cluster.face, character, size.to_bits()); + let glyph = *self + .upright_glyphs + .borrow_mut() + .entry(key) + .or_insert_with(|| { + self.catalog + .face_font(cluster.face) + .and_then(|face| face.upright_glyph(character, size)) + }); + let glyph = glyph.unwrap_or(UprightGlyph { + center: (ascent - descent) / 2.0, + half_advance: size / 2.0, + vertical_origin: ascent, + }); + ClusterOrientation::Upright { + center: baseline - glyph.center, + half_advance: glyph.half_advance, + vertical_origin: glyph.vertical_origin, + } + }) + .collect() + } + + /// How the combined upright unit `text` in `style` sets its string, + /// its primary font `ascent` and `descent` whole pixels (Blink's + /// `InlineNode::AdjustFontForTextCombineUprightAll` and + /// `LayoutTextCombine`). + fn combined_setting( + &self, + style: &InlineFormattingStyle, + text: &str, + ascent: f64, + descent: f64, + ) -> CombinedSetting { + let key = (text.to_owned(), style.font.clone()); + if let Some(setting) = self.combined_settings.borrow().get(&key) { + return *setting; + } + // The unit lays its text out horizontally, unspaced. + let mut horizontal = style.clone(); + horizontal.bidi.writing_mode = WritingMode::HorizontalTopToBottom; + horizontal.bidi.text_combine_upright = TextCombineUpright::None; + let width = |forms: CombinedForms| { + self.shaped_run_with(&horizontal, None, false, forms.feature(), text) + .advance as f32 + }; + let size = style.font.size.get(); + let desired = size * 1.1; + let content = width(CombinedForms::Proportional); + let (forms, used, scale_x) = if content == 0.0 || content <= desired { + (CombinedForms::Proportional, content, None) + } else { + [ + CombinedForms::Half, + CombinedForms::Third, + CombinedForms::Quarter, + ] + .into_iter() + .map(|forms| (forms, width(forms))) + .find(|(_, used)| *used <= desired) + .map_or( + ( + CombinedForms::Proportional, + content, + Some(desired / content), + ), + |(forms, used)| (forms, used, None), + ) + }; + // The box is the font's whole-pixel height across; the string + // centres in it on the 1/64 grid, or starts half its spare width + // in once scaled. + let height = ascent + descent; + let (_, below) = LineBaseline::Central.split(ascent, descent); + let inset = match scale_x { + Some(_) => (height - f64::from(desired)) / 2.0, + None => { + let spare = (height * 64.0) as i64 - (f64::from(used) * 64.0).ceil() as i64; + (spare / 2).max(0) as f64 / 64.0 + } + }; + let leading = height - f64::from(shaping_font_size(size)); + let setting = CombinedSetting { + across: below - inset, + down: layout_unit_trunc(ascent - leading / 2.0), + scale_x: scale_x.map_or(1.0, f64::from), + forms, + }; + self.combined_settings.borrow_mut().insert(key, setting); + setting + } +} + +/// The stretches of `text` (offsets into it) that stand upright in a +/// vertical line of `orientation`: a grapheme-extending mark keeps the +/// stance of the character before it (Blink's `OrientationIterator`). +pub(crate) fn upright_ranges( + orientation: TextOrientation, + text: &str, +) -> Vec> { + let extending = CodePointMapData::::new(); + let mut ranges: Vec> = Vec::new(); + let mut upright = false; + for (offset, character) in text.char_indices() { + let mark = matches!( + extending.get(character), + GeneralCategory::NonspacingMark + | GeneralCategory::EnclosingMark + | GeneralCategory::SpacingMark + ); + if offset == 0 || !mark { + upright = stands_upright(orientation, character); + } + if !upright { + continue; + } + let end = offset + character.len_utf8(); + match ranges.last_mut() { + Some(range) if range.end == offset => range.end = end, + _ => ranges.push(offset..end), + } + } + ranges +} + +/// Whether `character` stands upright in a vertical line of `orientation`. +fn stands_upright(orientation: TextOrientation, character: char) -> bool { + match orientation { + TextOrientation::Upright => true, + TextOrientation::Sideways => false, + TextOrientation::Mixed => { + CodePointMapData::::new().get(character) + != VerticalOrientation::Rotated + } + } +} + +impl Face { + /// The face's whole-pixel ascent and descent at `size`. + fn whole_pixel_extent(&self, size: f64) -> (f64, f64) { + let (ascent, descent, _) = self.rounded(size); + (ascent, descent) + } + + /// Where an upright glyph for `character` lies at `size`, from this + /// face's alphabetic baseline. + fn upright_glyph(&self, character: char, size: f64) -> Option { + let font = self.font_ref()?; + let upem = f64::from(font.head().ok()?.units_per_em()); + if upem <= 0.0 { + return None; + } + let scale = size / upem; + let (ascent, descent) = self.whole_pixel_extent(size); + let glyph = font.charmap().map(character)?; + let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default()); + let half_advance = f64::from(metrics.advance_width(glyph)?) * scale / 2.0; + let vertical_origin = if let Ok(vorg) = font.vorg() { + f64::from(vorg.vertical_origin_y(glyph)) * scale + } else if let (Ok(vmtx), Some(bounds)) = (font.vmtx(), metrics.bounds(glyph)) { + (f64::from(bounds.y_max) + f64::from(vmtx.side_bearing(glyph)?)) * scale + } else { + ascent + }; + Some(UprightGlyph { + center: ascent - (ascent + descent) / 2.0, + half_advance, + vertical_origin, + }) + } +} diff --git a/crates/rito-inline/src/width_bounds.rs b/crates/rito-inline/src/width_bounds.rs index 98338c7f0..84da32569 100644 --- a/crates/rito-inline/src/width_bounds.rs +++ b/crates/rito-inline/src/width_bounds.rs @@ -68,6 +68,13 @@ impl WidthBounds { self.hi = f64::NEG_INFINITY; } + /// Narrows the bounds to the sizes `other` holds for as well: the + /// decisions it recorded, replayed. + pub(crate) fn intersect(&mut self, other: &WidthBounds) { + self.lo = self.lo.max(other.lo); + self.hi = self.hi.min(other.hi); + } + /// The bounds, when they hold the inline size they were recorded at. pub(crate) fn range(&self) -> Option<(f64, f64)> { (self.lo <= self.inline_size && self.inline_size < self.hi).then_some((self.lo, self.hi)) diff --git a/crates/rito-style-contract/src/fragment.rs b/crates/rito-style-contract/src/fragment.rs index a3e006861..f9031d9a4 100644 --- a/crates/rito-style-contract/src/fragment.rs +++ b/crates/rito-style-contract/src/fragment.rs @@ -13,6 +13,18 @@ pub struct PhysicalSides { pub left: T, } +impl PhysicalSides { + /// The same value on every side. + pub const fn uniform(value: T) -> Self { + Self { + top: value, + right: value, + bottom: value, + left: value, + } + } +} + /// Elliptical radius for one border corner. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct CornerRadius { diff --git a/crates/rito-style-contract/src/inline.rs b/crates/rito-style-contract/src/inline.rs index d4f17620c..a4f8c7dee 100644 --- a/crates/rito-style-contract/src/inline.rs +++ b/crates/rito-style-contract/src/inline.rs @@ -1,6 +1,7 @@ use crate::{ - Direction, FontFamilies, FontSlant, FontWeight, InlineFragmentStyle, InlinePaintStyle, - InlineTextFlow, LineHeight, NonNegativeCssPx, UnicodeBidi, WritingMode, + BorderEdge, Direction, FontFamilies, FontSlant, FontWeight, InlineFragmentStyle, + InlinePaintStyle, InlineTextFlow, LengthPercentageOrAuto, LineHeight, NonNegativeCssPx, + NonNegativeLengthPercentage, TextCombineUpright, TextOrientation, UnicodeBidi, WritingMode, }; /// Font selection and line-metric inputs for inline formatting. @@ -36,6 +37,11 @@ pub struct InlineBidi { pub unicode_bidi: UnicodeBidi, /// Block/inline axis writing mode. pub writing_mode: WritingMode, + /// How characters stand in a vertical line. + pub text_orientation: TextOrientation, + /// Whether the text sets horizontally within one advance of a + /// vertical line. + pub text_combine_upright: TextCombineUpright, } /// First versioned, engine-neutral inline formatting contract. @@ -58,3 +64,81 @@ pub struct InlineFormattingStyle { /// Foreground, background, decoration, and shadow paint. pub paint: InlinePaintStyle, } + +/// An inline box's margin, border and padding at one end of its inline +/// axis: the start or end edge its content's lines begin or finish at. +#[derive(Clone, Copy, Debug)] +pub struct InlineEdge<'a> { + /// The margin on that side. + pub margin: &'a LengthPercentageOrAuto, + /// The border on that side. + pub border: &'a BorderEdge, + /// The padding on that side. + pub padding: &'a NonNegativeLengthPercentage, +} + +impl InlineEdge<'_> { + /// The edge's used size in CSS px, its percentages resolved against the + /// containing block's inline size `basis`; `auto` margins are zero on an + /// inline box. The margin and padding each land on the 1/64 px layout + /// grid truncated toward zero, as Blink's `LayoutUnit(float)` takes a + /// computed length; border widths are whole already. + pub fn size(&self, basis: Option) -> f32 { + let layout_unit = |px: f32| (px * 64.0).trunc() / 64.0; + let margin = match self.margin { + LengthPercentageOrAuto::Auto => 0.0, + LengthPercentageOrAuto::Value(value) => layout_unit(value.resolve(basis)), + }; + let padding = layout_unit(self.padding.value().resolve(basis).max(0.0)); + margin + self.border.resolved_width.get() + padding + } + + /// Whether the edge takes no space at any containing block size. + pub fn is_empty(&self) -> bool { + let margin = match self.margin { + LengthPercentageOrAuto::Auto => true, + LengthPercentageOrAuto::Value(value) => value.is_zero(), + }; + margin && self.border.resolved_width.get() == 0.0 && self.padding.value().is_zero() + } +} + +impl InlineFormattingStyle { + /// The box's edge where its content's lines start (`end` false) or end: + /// the left or right side in horizontal writing (swapped when lines run + /// right to left), the top or bottom side in vertical writing. + pub fn inline_edge(&self, end: bool) -> InlineEdge<'_> { + let fragment = &self.fragment; + let vertical = self.bidi.writing_mode != WritingMode::HorizontalTopToBottom; + // Lines start on the low side of the inline axis (left, or top) + // unless they run right to left. + let low = (self.bidi.direction == Direction::LeftToRight) != end; + let (margin, border, padding) = match (vertical, low) { + (false, true) => ( + &fragment.margin.left, + &fragment.border.left, + &fragment.padding.left, + ), + (false, false) => ( + &fragment.margin.right, + &fragment.border.right, + &fragment.padding.right, + ), + (true, true) => ( + &fragment.margin.top, + &fragment.border.top, + &fragment.padding.top, + ), + (true, false) => ( + &fragment.margin.bottom, + &fragment.border.bottom, + &fragment.padding.bottom, + ), + }; + InlineEdge { + margin, + border, + padding, + } + } +} diff --git a/crates/rito-style-contract/src/layout.rs b/crates/rito-style-contract/src/layout.rs index cf99cc079..147927109 100644 --- a/crates/rito-style-contract/src/layout.rs +++ b/crates/rito-style-contract/src/layout.rs @@ -1,8 +1,8 @@ use std::{collections::HashMap, fmt}; use crate::{ - LengthPercentageOrAuto, NonNegativeCssPx, NonNegativeLengthPercentage, Percentage, - PhysicalSides, + Direction, LengthPercentageOrAuto, LogicalSides, NonNegativeCssPx, NonNegativeLengthPercentage, + Percentage, PhysicalSides, WritingDirection, WritingMode, }; /// Outer display role retained from the computed CSS display pair. @@ -85,7 +85,8 @@ pub enum PreferredSize { FitContentFunction(NonNegativeLengthPercentage), } -/// Computed `max-width` values retained for layout and capability diagnostics. +/// Computed `max-width` / `max-height` values retained for layout and +/// capability diagnostics. /// /// Intrinsic sizing is retained exactly by the producer. Consumers currently /// omit these caps as a compatibility fallback, preserving the rest of the @@ -135,27 +136,18 @@ pub enum Overflow { Hidden, } -/// Computed `min-height` values retained at the current consumer boundary. +/// Computed `min-width` / `min-height` values retained at the current +/// consumer boundary. /// /// Percentages remain distinct even though the consumer currently applies the -/// legacy compatibility policy of omitting them without a height basis. +/// legacy compatibility policy of omitting them without a basis. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum MinimumHeight { +pub enum MinimumSize { Auto, Length(NonNegativeCssPx), Percentage(Percentage), } -/// Computed `max-height` values retained at the current consumer boundary. -/// -/// Percentages remain distinct from `none`; see [`MinimumHeight`]. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum MaximumHeight { - None, - Length(NonNegativeCssPx), - Percentage(Percentage), -} - /// List marker systems implemented by the pre-Stylo Rito layout consumer. /// /// Producers must reject other counter-style names, strings, and `symbols()` @@ -177,11 +169,21 @@ pub enum ListMarkerStyle { #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct LayoutFormattingStyle { pub display: LayoutDisplay, + /// The box's writing mode and inline base direction: which physical + /// sides and axes its flow-relative edges and sizes fall on (see + /// [`LayoutFormattingStyle::writing_direction`]). + pub writing_mode: WritingMode, + pub direction: Direction, /// Physical box margins. Percentages resolve against the containing /// block's inline size (including the vertical sides, per CSS). pub margin: PhysicalSides, /// Physical box padding. pub padding: PhysicalSides, + /// Used border widths. The formatting bridge folds every border into + /// `padding`, so layout reads these only where CSS tells a border + /// from padding: a ruby annotation reaches into a box's top padding + /// and the margins above it, never across a top border. + pub border_widths: PhysicalSides, /// How `width`/`height` map onto the box model. pub box_sizing: BoxSizing, pub justify_content: JustifyContent, @@ -190,9 +192,10 @@ pub struct LayoutFormattingStyle { pub break_after: PageBreak, pub width: PreferredSize, pub height: PreferredSize, + pub min_width: MinimumSize, pub max_width: MaximumSize, - pub min_height: MinimumHeight, - pub max_height: MaximumHeight, + pub min_height: MinimumSize, + pub max_height: MaximumSize, pub clear: Clear, pub float: Float, pub overflow: Overflow, @@ -220,6 +223,79 @@ pub struct LayoutFormattingStyle { pub object_fit: ObjectFit, } +impl LayoutFormattingStyle { + /// The box's own writing mode and direction. + pub fn writing_direction(&self) -> WritingDirection { + WritingDirection { + mode: self.writing_mode, + direction: self.direction, + } + } + + /// The box's margins on the flow-relative edges of the containing + /// block's flow, laid out in `container`: margins position a box in + /// its container's flow. + pub fn margin_in(&self, container: WritingDirection) -> LogicalSides { + self.margin.to_logical(container) + } + + /// The box's offsets on the flow-relative edges of its containing + /// block, laid out in `container`. + pub fn inset_in(&self, container: WritingDirection) -> LogicalSides { + self.inset.to_logical(container) + } + + /// The box's padding on its own flow-relative edges. + pub fn flow_padding(&self) -> LogicalSides { + self.padding.to_logical(self.writing_direction()) + } + + /// The box's border widths on its own flow-relative edges. + pub fn flow_border_widths(&self) -> LogicalSides { + self.border_widths.to_logical(self.writing_direction()) + } + + /// `width` or `height`, whichever runs along the box's lines. + pub fn inline_size(&self) -> PreferredSize { + self.writing_direction() + .axes(self.width, self.height) + .inline + } + + /// `width` or `height`, whichever runs across the box's lines. + pub fn block_size(&self) -> PreferredSize { + self.writing_direction().axes(self.width, self.height).block + } + + /// The minimum of [`Self::inline_size`]. + pub fn min_inline_size(&self) -> MinimumSize { + self.writing_direction() + .axes(self.min_width, self.min_height) + .inline + } + + /// The minimum of [`Self::block_size`]. + pub fn min_block_size(&self) -> MinimumSize { + self.writing_direction() + .axes(self.min_width, self.min_height) + .block + } + + /// The maximum of [`Self::inline_size`]. + pub fn max_inline_size(&self) -> MaximumSize { + self.writing_direction() + .axes(self.max_width, self.max_height) + .inline + } + + /// The maximum of [`Self::block_size`]. + pub fn max_block_size(&self) -> MaximumSize { + self.writing_direction() + .axes(self.max_width, self.max_height) + .block + } +} + /// Computed `object-fit` values (CSS Images 3 §5.4). #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ObjectFit { diff --git a/crates/rito-style-contract/src/length.rs b/crates/rito-style-contract/src/length.rs index 4bf5e4af3..172d13023 100644 --- a/crates/rito-style-contract/src/length.rs +++ b/crates/rito-style-contract/src/length.rs @@ -26,6 +26,27 @@ impl LengthPercentage { pub const fn linear(length: CssPx, percentage: Percentage) -> Self { Self::Linear { length, percentage } } + + /// The used value against a percentage `basis` in CSS px (a percentage + /// term resolves to zero without one). + pub fn resolve(self, basis: Option) -> f32 { + let percent = + |percentage: Percentage| basis.map_or(0.0, |basis| percentage.ratio() * basis); + match self { + Self::Length(length) => length.get(), + Self::Percentage(percentage) => percent(percentage), + Self::Linear { length, percentage } => length.get() + percent(percentage), + } + } + + /// Whether the value is zero at every basis. + pub fn is_zero(self) -> bool { + match self { + Self::Length(length) => length.get() == 0.0, + Self::Percentage(percentage) => percentage.ratio() == 0.0, + Self::Linear { length, percentage } => length.get() == 0.0 && percentage.ratio() == 0.0, + } + } } /// A `` with CSS's non-negative range constraint retained. diff --git a/crates/rito-style-contract/src/lib.rs b/crates/rito-style-contract/src/lib.rs index d87f854f5..bae16f30f 100644 --- a/crates/rito-style-contract/src/lib.rs +++ b/crates/rito-style-contract/src/lib.rs @@ -33,6 +33,7 @@ mod generated; mod inline; mod layout; mod length; +mod logical; mod paint; mod scalar; mod table; @@ -49,14 +50,15 @@ pub use fragment::{ InlineFragmentStyle, PhysicalSides, }; pub use generated::{GeneratedBox, GeneratedBoxTable, GeneratedContentItem, PseudoElementKind}; -pub use inline::{FontStyle, InlineBidi, InlineFormattingStyle}; +pub use inline::{FontStyle, InlineBidi, InlineEdge, InlineFormattingStyle}; pub use layout::{ AlignItems, BoxSizing, CellVerticalAlign, Clear, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleId, - LayoutStyleTable, LayoutStyleTableError, ListMarkerStyle, MaximumHeight, MaximumSize, - MinimumHeight, ObjectFit, Overflow, PageBreak, Position, PreferredSize, + LayoutStyleTable, LayoutStyleTableError, ListMarkerStyle, MaximumSize, MinimumSize, ObjectFit, + Overflow, PageBreak, Position, PreferredSize, }; pub use length::{LengthPercentage, LengthPercentageOrAuto, NonNegativeLengthPercentage}; +pub use logical::{LogicalAxes, LogicalSides, WritingDirection}; pub use paint::{ BackgroundImagePaint, BackgroundImagePosition, BackgroundImageRepeat, BackgroundImageSize, BackgroundSizeAxis, BorderEdge, BorderEdges, BorderStyle, BoxShadow, InlinePaintStyle, @@ -69,8 +71,9 @@ pub use scalar::{ }; pub use table::{InlineStyleTable, StyleId, StyleTableError}; pub use text::{ - Direction, InlineTextFlow, LanguageTag, LineBreak, OverflowWrap, RubyAlign, TextAlign, - TextIndent, TextJustify, TextTransform, TextTransformCase, TextWrapMode, UnicodeBidi, - WhiteSpaceCollapse, WordBreak, WritingMode, + Direction, InlineTextFlow, LanguageTag, LineBreak, LineSide, OverflowWrap, RubyAlign, + TextAlign, TextCombineUpright, TextEmphasis, TextIndent, TextJustify, TextOrientation, + TextTransform, TextTransformCase, TextWrapMode, UnicodeBidi, WhiteSpaceCollapse, WordBreak, + WritingMode, }; pub use transform::{TransformList, TransformListError, TransformOperation}; diff --git a/crates/rito-style-contract/src/logical.rs b/crates/rito-style-contract/src/logical.rs new file mode 100644 index 000000000..9c81e6d12 --- /dev/null +++ b/crates/rito-style-contract/src/logical.rs @@ -0,0 +1,232 @@ +//! Flow-relative geometry (CSS Writing Modes 3 §6): which physical side +//! a box's inline and block edges fall on under its writing mode and +//! direction, and which physical axis carries each of its sizes. Layout +//! reads box styles through this mapping and stays in flow-relative +//! terms; what paints or hit-tests converts back. + +use crate::{Direction, PhysicalSides, WritingMode}; + +/// A box's writing mode together with its inline base direction: what +/// decides the physical side of each flow-relative edge. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct WritingDirection { + /// Block/inline axis orientation. + pub mode: WritingMode, + /// Whether the inline axis runs against its physical direction. + pub direction: Direction, +} + +impl WritingDirection { + /// Horizontal lines stacking downwards, left to right: the initial + /// writing mode and direction. + pub const HORIZONTAL_LTR: Self = Self { + mode: WritingMode::HorizontalTopToBottom, + direction: Direction::LeftToRight, + }; + + /// Whether lines run horizontally. + pub fn is_horizontal(self) -> bool { + self.mode == WritingMode::HorizontalTopToBottom + } + + /// Whether the block axis progresses from right to left (vertical-rl), + /// so a block-start edge is the physical right. + pub fn is_flipped_blocks(self) -> bool { + self.mode == WritingMode::VerticalRightToLeft + } + + /// Whether the inline axis runs from the physical right (or bottom) + /// towards the left (or top). + pub fn is_rtl(self) -> bool { + self.direction == Direction::RightToLeft + } + + /// Whether `other`'s lines run along the same physical axis as these: + /// a box whose writing mode is not parallel to its container's is an + /// orthogonal flow root. + pub fn is_parallel_to(self, other: Self) -> bool { + self.is_horizontal() == other.is_horizontal() + } + + /// A physical width and height as this writing mode's inline and block + /// sizes. + pub fn axes(self, width: T, height: T) -> LogicalAxes { + if self.is_horizontal() { + LogicalAxes { + inline: width, + block: height, + } + } else { + LogicalAxes { + inline: height, + block: width, + } + } + } + + /// Inline and block sizes as the physical width and height. + pub fn physical_axes(self, axes: LogicalAxes) -> (T, T) { + if self.is_horizontal() { + (axes.inline, axes.block) + } else { + (axes.block, axes.inline) + } + } +} + +/// A value per flow-relative axis. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct LogicalAxes { + /// Along the lines. + pub inline: T, + /// Across the lines, in the direction blocks stack. + pub block: T, +} + +/// A value per flow-relative edge. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct LogicalSides { + /// Where lines start. + pub inline_start: T, + /// Where lines end. + pub inline_end: T, + /// Where the first line sits. + pub block_start: T, + /// Where the last line sits. + pub block_end: T, +} + +impl PhysicalSides { + /// These sides as the edges of a box laid out in `writing`. + pub fn to_logical(self, writing: WritingDirection) -> LogicalSides { + let (block_start, block_end, inline_low, inline_high) = match writing.mode { + WritingMode::HorizontalTopToBottom => (self.top, self.bottom, self.left, self.right), + WritingMode::VerticalRightToLeft => (self.right, self.left, self.top, self.bottom), + WritingMode::VerticalLeftToRight => (self.left, self.right, self.top, self.bottom), + }; + let (inline_start, inline_end) = if writing.is_rtl() { + (inline_high, inline_low) + } else { + (inline_low, inline_high) + }; + LogicalSides { + inline_start, + inline_end, + block_start, + block_end, + } + } +} + +impl LogicalSides { + /// These edges as the physical sides of a box laid out in `writing`. + pub fn to_physical(self, writing: WritingDirection) -> PhysicalSides { + let (inline_low, inline_high) = if writing.is_rtl() { + (self.inline_end, self.inline_start) + } else { + (self.inline_start, self.inline_end) + }; + match writing.mode { + WritingMode::HorizontalTopToBottom => PhysicalSides { + top: self.block_start, + right: inline_high, + bottom: self.block_end, + left: inline_low, + }, + WritingMode::VerticalRightToLeft => PhysicalSides { + top: inline_low, + right: self.block_start, + bottom: inline_high, + left: self.block_end, + }, + WritingMode::VerticalLeftToRight => PhysicalSides { + top: inline_low, + right: self.block_end, + bottom: inline_high, + left: self.block_start, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIDES: PhysicalSides = PhysicalSides { + top: 't', + right: 'r', + bottom: 'b', + left: 'l', + }; + + fn writing(mode: WritingMode, direction: Direction) -> WritingDirection { + WritingDirection { mode, direction } + } + + #[test] + fn each_writing_mode_puts_the_flow_edges_on_its_physical_sides() { + // (inline-start, inline-end, block-start, block-end), per + // css-writing-modes-3 §6.4's table. + let cases = [ + ( + WritingMode::HorizontalTopToBottom, + Direction::LeftToRight, + "lrtb", + ), + ( + WritingMode::HorizontalTopToBottom, + Direction::RightToLeft, + "rltb", + ), + ( + WritingMode::VerticalRightToLeft, + Direction::LeftToRight, + "tbrl", + ), + ( + WritingMode::VerticalRightToLeft, + Direction::RightToLeft, + "btrl", + ), + ( + WritingMode::VerticalLeftToRight, + Direction::LeftToRight, + "tblr", + ), + ( + WritingMode::VerticalLeftToRight, + Direction::RightToLeft, + "btlr", + ), + ]; + for (mode, direction, expected) in cases { + let logical = SIDES.to_logical(writing(mode, direction)); + let got: String = [ + logical.inline_start, + logical.inline_end, + logical.block_start, + logical.block_end, + ] + .iter() + .collect(); + assert_eq!(got, expected, "{mode:?} {direction:?}"); + assert_eq!( + logical.to_physical(writing(mode, direction)), + SIDES, + "{mode:?} {direction:?} round trip" + ); + } + } + + #[test] + fn a_vertical_box_reads_its_height_as_its_inline_size() { + let vertical = writing(WritingMode::VerticalRightToLeft, Direction::LeftToRight); + let axes = vertical.axes("width", "height"); + assert_eq!((axes.inline, axes.block), ("height", "width")); + assert_eq!(vertical.physical_axes(axes), ("width", "height")); + let horizontal = WritingDirection::HORIZONTAL_LTR.axes("width", "height"); + assert_eq!((horizontal.inline, horizontal.block), ("width", "height")); + assert!(!vertical.is_parallel_to(WritingDirection::HORIZONTAL_LTR)); + } +} diff --git a/crates/rito-style-contract/src/scalar.rs b/crates/rito-style-contract/src/scalar.rs index 4cd460555..826198418 100644 --- a/crates/rito-style-contract/src/scalar.rs +++ b/crates/rito-style-contract/src/scalar.rs @@ -122,6 +122,9 @@ impl From for CssPx { pub struct NonNegativeCssPx(FiniteF32); impl NonNegativeCssPx { + /// Zero CSS pixels. + pub const ZERO: Self = Self(FiniteF32(0.0)); + /// Creates a finite, non-negative CSS-pixel length. pub fn new(value: f32) -> Result { let value = FiniteF32::new(value)?; diff --git a/crates/rito-style-contract/src/text.rs b/crates/rito-style-contract/src/text.rs index bdb67f266..793070d67 100644 --- a/crates/rito-style-contract/src/text.rs +++ b/crates/rito-style-contract/src/text.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::LengthPercentage; +use crate::{ComputedColor, LengthPercentage}; /// Inline/base direction used for logical ordering. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -22,6 +22,30 @@ pub enum WritingMode { VerticalLeftToRight, } +/// Computed `text-orientation` (css-writing-modes-3 §5.1): how the +/// characters of a vertical line stand. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TextOrientation { + /// Characters whose Unicode vertical orientation is upright stand + /// upright; the rest lie sideways. + Mixed, + /// Every character stands upright. + Upright, + /// Every character lies sideways, as in a horizontal line turned a + /// quarter clockwise. + Sideways, +} + +/// Computed `text-combine-upright` (css-writing-modes-3 §9.1). +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TextCombineUpright { + /// Characters set one by one along the line. + None, + /// The element's text sets horizontally within a single advance of + /// a vertical line. + All, +} + /// Unicode bidi control for an element. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum UnicodeBidi { @@ -78,6 +102,31 @@ pub enum RubyAlign { SpaceBetween, } +/// Computed `text-emphasis`: the mark every character that takes one +/// receives, the side of its line the marks stand on and their color. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct TextEmphasis { + /// The mark: a shape keyword's character (its `filled` or `open` + /// form, the shape `auto` resolving by typographic mode), or the + /// custom string. + pub mark: Arc, + /// The side of the line the marks stand on. + pub side: LineSide, + /// Computed mark color with `currentColor` retained symbolically. + pub color: ComputedColor, +} + +/// A line-relative side: `Over` is the ascent side of a horizontal line +/// and the right of a vertical one (`vertical-lr` included), `Under` the +/// other. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum LineSide { + /// The line-over side. + Over, + /// The line-under side. + Under, +} + /// Computed text justification strategy. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum TextJustify { @@ -252,6 +301,8 @@ pub struct InlineTextFlow { pub text_indent: TextIndent, /// Ruby annotation/base distribution. pub ruby_align: RubyAlign, + /// Emphasis marks, absent for computed `none`. + pub text_emphasis: Option, /// Inherited source language, or `None` when unspecified. pub language: Option, } diff --git a/crates/rito-style-contract/tests/style_table.rs b/crates/rito-style-contract/tests/style_table.rs index a7ec0f735..ebba4d2fd 100644 --- a/crates/rito-style-contract/tests/style_table.rs +++ b/crates/rito-style-contract/tests/style_table.rs @@ -70,6 +70,8 @@ fn style(seed: f32) -> InlineFormattingStyle { direction: Direction::LeftToRight, unicode_bidi: UnicodeBidi::Normal, writing_mode: WritingMode::HorizontalTopToBottom, + text_orientation: rito_style_contract::TextOrientation::Mixed, + text_combine_upright: rito_style_contract::TextCombineUpright::None, }, fragment: InlineFragmentStyle { margin: sides(LengthPercentageOrAuto::Value(length(seed))), @@ -150,6 +152,7 @@ fn text_flow(seed: f32) -> InlineTextFlow { each_line: false, }, ruby_align: RubyAlign::SpaceAround, + text_emphasis: None, language: None, } } diff --git a/crates/rito-stylo/src/break_properties.rs b/crates/rito-stylo/src/break_properties.rs index b92f6af8f..e228a793c 100644 --- a/crates/rito-stylo/src/break_properties.rs +++ b/crates/rito-stylo/src/break_properties.rs @@ -30,6 +30,31 @@ pub(crate) const REGISTRATION_STYLESHEET: &str = r#" inherits: true; initial-value: space-around; } +@property --rito-internal-text-orientation-v1 { + syntax: "mixed | upright | sideways"; + inherits: true; + initial-value: mixed; +} +@property --rito-internal-text-combine-upright-v1 { + syntax: "none | all"; + inherits: true; + initial-value: none; +} +@property --rito-internal-text-emphasis-style-v1 { + syntax: "none | filled-auto | open-auto | filled-dot | open-dot | filled-circle | open-circle | filled-double-circle | open-double-circle | filled-triangle | open-triangle | filled-sesame | open-sesame | "; + inherits: true; + initial-value: none; +} +@property --rito-internal-text-emphasis-color-v1 { + syntax: ""; + inherits: true; + initial-value: currentcolor; +} +@property --rito-internal-text-emphasis-position-v1 { + syntax: "over-right | over-left | under-right | under-left"; + inherits: true; + initial-value: over-right; +} "#; const BEFORE_CSS_NAME: &str = "--rito-internal-break-before"; @@ -41,6 +66,13 @@ const BORDER_SPACING_CSS_NAME: &str = "--rito-internal-border-spacing-v1"; // `ruby-align` is a Gecko-only longhand in stylo's Servo profile, so it // travels as a registered custom property like the table borders. const RUBY_ALIGN_CSS_NAME: &str = "--rito-internal-ruby-align-v1"; +// `text-orientation` and `text-combine-upright` are Gecko-only longhands in +// stylo's Servo profile too; their legacy prefixed forms reach them with +// the keyword the browser maps each prefixed value to. +const TEXT_ORIENTATION_CSS_NAME: &str = "--rito-internal-text-orientation-v1"; +const TEXT_COMBINE_UPRIGHT_CSS_NAME: &str = "--rito-internal-text-combine-upright-v1"; +const TEXT_ORIENTATION_ATOM_NAME: &str = "rito-internal-text-orientation-v1"; +const TEXT_COMBINE_UPRIGHT_ATOM_NAME: &str = "rito-internal-text-combine-upright-v1"; const BORDER_COLLAPSE_ATOM_NAME: &str = "rito-internal-border-collapse-v1"; const BORDER_SPACING_ATOM_NAME: &str = "rito-internal-border-spacing-v1"; const RUBY_ALIGN_ATOM_NAME: &str = "rito-internal-ruby-align-v1"; @@ -155,25 +187,60 @@ fn rewrite(css: &str, declaration_list: bool) -> Cow<'_, str> { } byte if candidate && is_name_start(byte) => { let end = name_end(bytes, cursor); - let replacement = match replacement_name(&css[cursor..end]) { - Some(name) if name == RUBY_ALIGN_CSS_NAME => { - // Only a value the browser's parser accepts may - // ride the registered property. An invalid value - // (`inter-character`, `auto` in the corpus) keeps - // its original name so the declaration drops at - // parse time exactly like the browser drops it; - // rewritten, it would win the cascade and reset - // an earlier valid declaration to the initial. - ruby_align_value_accepted(css, end).then_some(name) + // Only a value the browser's parser accepts for the + // property may ride a keyword carrier. An invalid value + // (`ruby-align: inter-character`, `auto` in the corpus) + // keeps its original name so the declaration drops at + // parse time exactly like the browser drops it; + // rewritten, it would win the cascade and reset an + // earlier valid declaration to the initial. + let found = carrier(&css[cursor..end]); + if let Some(CarriedValue::Rewritten(rewrite)) = found.as_ref().map(|c| &c.value) { + let declaration = declaration_value(css, end); + let carried = declaration + .as_ref() + .and_then(|declaration| rewrite(&css[declaration.value.clone()])); + if let (Some(declaration), Some(carried)) = (declaration, carried) { + output.push_str(&css[copied_until..cursor]); + let priority = if declaration.important { + " !important" + } else { + "" + }; + let written: Vec = carried + .iter() + .map(|(name, value)| format!("{name}: {value}{priority}")) + .collect(); + output.push_str(&written.join("; ")); + copied_until = declaration.end; + candidate = false; + cursor = declaration.end; + continue; } - other => other, - }; + } + let carried = found.and_then(|carrier| { + let keywords = match carrier.value { + CarriedValue::AsWritten => return Some((carrier.name, None)), + CarriedValue::Keyword(keywords) => keywords, + CarriedValue::Rewritten(_) => return None, + }; + let (range, keyword) = declaration_keyword(css, end)?; + let stored = stored_keyword(keywords, &keyword)?; + let rewritten = + (!stored.eq_ignore_ascii_case(&keyword)).then_some((range, stored)); + Some((carrier.name, rewritten)) + }); if next_significant_byte(bytes, end) == Some(b':') { in_declaration_value = true; - if let Some(replacement) = replacement { + if let Some((name, value)) = carried { output.push_str(&css[copied_until..cursor]); - output.push_str(replacement); + output.push_str(name); copied_until = end; + if let Some((range, stored)) = value { + output.push_str(&css[end..range.start]); + output.push_str(stored); + copied_until = range.end; + } } } candidate = false; @@ -194,38 +261,205 @@ fn rewrite(css: &str, declaration_list: bool) -> Cow<'_, str> { } } -fn replacement_name(name: &str) -> Option<&'static str> { - if name.eq_ignore_ascii_case("break-before") || name.eq_ignore_ascii_case("page-break-before") { - Some(BEFORE_CSS_NAME) - } else if name.eq_ignore_ascii_case("break-after") - || name.eq_ignore_ascii_case("page-break-after") - { - Some(AFTER_CSS_NAME) - } else if name.eq_ignore_ascii_case("border-collapse") { - Some(BORDER_COLLAPSE_CSS_NAME) - } else if name.eq_ignore_ascii_case("border-spacing") { - Some(BORDER_SPACING_CSS_NAME) - } else if name.eq_ignore_ascii_case("ruby-align") { - Some(RUBY_ALIGN_CSS_NAME) - } else if name.eq_ignore_ascii_case("-webkit-writing-mode") - || name.eq_ignore_ascii_case("-epub-writing-mode") - { +/// The keywords the browser's parser accepts for one property name, each +/// with the keyword the carrier stores for it. +type KeywordMap = &'static [(&'static str, &'static str)]; + +const RUBY_ALIGN_KEYWORDS: KeywordMap = &[ + ("space-around", "space-around"), + ("start", "start"), + ("center", "center"), + ("space-between", "space-between"), +]; +const TEXT_ORIENTATION_KEYWORDS: KeywordMap = &[ + ("mixed", "mixed"), + ("upright", "upright"), + ("sideways", "sideways"), + ("sideways-right", "sideways"), +]; +// Blink's `-webkit-text-orientation` grammar: `vertical-right` is the +// legacy name of `mixed`, `sideways-right` of `sideways`. +const WEBKIT_TEXT_ORIENTATION_KEYWORDS: KeywordMap = &[ + ("sideways", "sideways"), + ("sideways-right", "sideways"), + ("upright", "upright"), + ("vertical-right", "mixed"), +]; +const TEXT_COMBINE_UPRIGHT_KEYWORDS: KeywordMap = &[("none", "none"), ("all", "all")]; +const WEBKIT_LINE_BREAK_KEYWORDS: KeywordMap = &[ + ("auto", "auto"), + ("loose", "loose"), + ("normal", "normal"), + ("strict", "strict"), +]; +// Blink's `-webkit-text-combine`: `horizontal` is `text-combine-upright: +// all`. +const WEBKIT_TEXT_COMBINE_KEYWORDS: KeywordMap = &[("none", "none"), ("horizontal", "all")]; + +/// Where a declaration stylo's Servo profile would drop travels instead. +struct Carrier { + /// The property it is renamed to. + name: &'static str, + /// What its value becomes. + value: CarriedValue, +} + +/// How a carried declaration's value travels. +enum CarriedValue { + /// As written; its projection reads it. + AsWritten, + /// One of these keywords, as the carrier stores it. + Keyword(KeywordMap), + /// Checked and rewritten whole: the declarations it becomes, or none + /// when the browser's grammar rejects it. + Rewritten(fn(&str) -> Option), +} + +fn carrier(name: &str) -> Option { + let is = |candidate: &str| name.eq_ignore_ascii_case(candidate); + let renamed = |name| Carrier { + name, + value: CarriedValue::AsWritten, + }; + let keyword = |name, keywords| Carrier { + name, + value: CarriedValue::Keyword(keywords), + }; + let rewritten = |name, rewrite| Carrier { + name, + value: CarriedValue::Rewritten(rewrite), + }; + use crate::text_emphasis as emphasis; + Some(if is("break-before") || is("page-break-before") { + renamed(BEFORE_CSS_NAME) + } else if is("break-after") || is("page-break-after") { + renamed(AFTER_CSS_NAME) + } else if is("border-collapse") { + renamed(BORDER_COLLAPSE_CSS_NAME) + } else if is("border-spacing") { + renamed(BORDER_SPACING_CSS_NAME) + } else if is("ruby-align") { + keyword(RUBY_ALIGN_CSS_NAME, RUBY_ALIGN_KEYWORDS) + } else if is("-webkit-writing-mode") || is("-epub-writing-mode") { // Blink parses both prefixed forms as aliases of `writing-mode` // (same value grammar); vertical EPUB books commonly declare ONLY // the prefixed forms, which stylo's servo profile drops as // unknown properties. - Some("writing-mode") + renamed("writing-mode") + } else if is("-webkit-line-break") { + // Blink's surrogate for `line-break`, without `anywhere` (and its + // `after-white-space`, which stylo has no value for). + keyword("line-break", WEBKIT_LINE_BREAK_KEYWORDS) + } else if is("-epub-word-break") { + renamed("word-break") + } else if is("-epub-text-transform") { + renamed("text-transform") + } else if is("-epub-caption-side") { + renamed("caption-side") + } else if is("text-orientation") { + keyword(TEXT_ORIENTATION_CSS_NAME, TEXT_ORIENTATION_KEYWORDS) + } else if is("-webkit-text-orientation") || is("-epub-text-orientation") { + keyword(TEXT_ORIENTATION_CSS_NAME, WEBKIT_TEXT_ORIENTATION_KEYWORDS) + } else if is("text-combine-upright") { + keyword(TEXT_COMBINE_UPRIGHT_CSS_NAME, TEXT_COMBINE_UPRIGHT_KEYWORDS) + } else if is("-webkit-text-combine") || is("-epub-text-combine") { + keyword(TEXT_COMBINE_UPRIGHT_CSS_NAME, WEBKIT_TEXT_COMBINE_KEYWORDS) + } else if is("text-emphasis-style") + || is("-webkit-text-emphasis-style") + || is("-epub-text-emphasis-style") + { + rewritten(emphasis::STYLE_CSS_NAME, emphasis::carry_style) + } else if is("text-emphasis-color") + || is("-webkit-text-emphasis-color") + || is("-epub-text-emphasis-color") + { + rewritten(emphasis::COLOR_CSS_NAME, emphasis::carry_color) + } else if is("text-emphasis-position") || is("-webkit-text-emphasis-position") { + // Blink has no `-epub-` spelling of the position. + rewritten(emphasis::POSITION_CSS_NAME, emphasis::carry_position) + } else if is("text-emphasis") || is("-webkit-text-emphasis") || is("-epub-text-emphasis") { + rewritten(emphasis::STYLE_CSS_NAME, emphasis::carry_shorthand) } else { - None + return None; + }) +} + +/// The keyword `keywords` stores for `value`: its own mapping, or the +/// value itself when it is a CSS-wide keyword, which every property +/// accepts. +fn stored_keyword(keywords: KeywordMap, value: &str) -> Option<&'static str> { + const CSS_WIDE: [&str; 5] = ["inherit", "initial", "unset", "revert", "revert-layer"]; + if let Some(wide) = CSS_WIDE + .iter() + .find(|wide| wide.eq_ignore_ascii_case(value)) + { + return Some(wide); + } + keywords + .iter() + .find(|(accepted, _)| accepted.eq_ignore_ascii_case(value)) + .map(|(_, stored)| *stored) +} + +/// The single keyword a declaration's value is, with its byte range, +/// when the value following `after_name` (the byte after the property +/// name) is exactly one identifier, ignoring comments and a trailing +/// `!important`. +fn declaration_keyword(css: &str, after_name: usize) -> Option<(std::ops::Range, String)> { + let bytes = css.as_bytes(); + let skip = |mut cursor: usize| loop { + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + if bytes.get(cursor) == Some(&b'/') && bytes.get(cursor + 1) == Some(&b'*') { + cursor = comment_end(bytes, cursor); + continue; + } + break cursor; + }; + let mut cursor = skip(after_name); + if bytes.get(cursor) != Some(&b':') { + return None; + } + cursor = skip(cursor + 1); + if !bytes.get(cursor).copied().is_some_and(is_name_start) { + return None; + } + let start = cursor; + cursor = name_end(bytes, cursor); + let range = start..cursor; + cursor = skip(cursor); + if bytes.get(cursor) == Some(&b'!') { + cursor = skip(cursor + 1); + let end = name_end(bytes, cursor); + if !css[cursor..end].eq_ignore_ascii_case("important") { + return None; + } + cursor = skip(end); } + matches!(bytes.get(cursor), None | Some(b';' | b'}')).then(|| { + let keyword = css[range.clone()].to_owned(); + (range, keyword) + }) +} + +/// A declaration's value: its text (a trailing `!important` left out), +/// whether it is important, and the byte that ends it (its `;` or `}`, +/// or the end of the text). +struct DeclarationValue { + value: std::ops::Range, + important: bool, + end: usize, } -/// Whether the declaration value following `name_end` (the byte after the -/// `ruby-align` property name) is one of the keywords the property -/// grammar accepts, ignoring comments and a trailing `!important`. -fn ruby_align_value_accepted(css: &str, name_end: usize) -> bool { +/// The value following `after_name` (the byte after a property name), +/// scanned to the top-level `;` or `}` that ends it. +fn declaration_value(css: &str, after_name: usize) -> Option { let bytes = css.as_bytes(); - let mut cursor = name_end; + let mut cursor = after_name; loop { while bytes .get(cursor) @@ -240,28 +474,49 @@ fn ruby_align_value_accepted(css: &str, name_end: usize) -> bool { break; } if bytes.get(cursor) != Some(&b':') { - return false; + return None; } - cursor += 1; - let mut value = String::new(); - while cursor < bytes.len() { - match bytes[cursor] { - b';' | b'{' | b'}' => break, - b'/' if bytes.get(cursor + 1) == Some(&b'*') => cursor = comment_end(bytes, cursor), - byte => { - value.push(byte as char); - cursor += 1; + let start = cursor + 1; + cursor = start; + let mut depth = 0_u32; + let mut bang = None; + while let Some(&byte) = bytes.get(cursor) { + match byte { + b'\'' | b'"' => { + cursor = quoted_end(bytes, cursor); + continue; + } + b'/' if bytes.get(cursor + 1) == Some(&b'*') => { + cursor = comment_end(bytes, cursor); + continue; } + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' => depth = depth.saturating_sub(1), + b'}' if depth > 0 => depth -= 1, + b';' | b'}' if depth == 0 => break, + b'!' if depth == 0 => bang = Some(cursor), + _ => {} } + cursor += 1; } - let value = value.to_ascii_lowercase(); - let value = value.trim(); - let value = match value.rfind('!') { - Some(index) if value[index + 1..].trim() == "important" => value[..index].trim_end(), - Some(_) => return false, - None => value, + let end = cursor; + let important = bang.is_some_and(|bang| { + let after = &css[bang + 1..end]; + let word = after.trim_start(); + let word_start = end - after.len() + (after.len() - word.len()); + let word_end = name_end(bytes, word_start); + css[word_start..word_end].eq_ignore_ascii_case("important") + && css[word_end..end].trim().is_empty() + }); + let value_end = match bang { + Some(bang) if important => bang, + _ => end, }; - matches!(value, "space-around" | "start" | "center" | "space-between") + Some(DeclarationValue { + value: start..value_end, + important, + end, + }) } /// The used table cell separation: `border-collapse: collapse` removes it, @@ -327,6 +582,38 @@ pub(crate) fn project_ruby_align(styles: &ComputedValues) -> rito_style_contract } } +/// The used `text-orientation`, read off its carrier. +pub(crate) fn project_text_orientation( + styles: &ComputedValues, +) -> rito_style_contract::TextOrientation { + use rito_style_contract::TextOrientation; + match inherited_keyword(styles, TEXT_ORIENTATION_ATOM_NAME).as_deref() { + Some("upright") => TextOrientation::Upright, + Some("sideways") => TextOrientation::Sideways, + _ => TextOrientation::Mixed, + } +} + +/// The used `text-combine-upright`, read off its carrier. +pub(crate) fn project_text_combine_upright( + styles: &ComputedValues, +) -> rito_style_contract::TextCombineUpright { + use rito_style_contract::TextCombineUpright; + match inherited_keyword(styles, TEXT_COMBINE_UPRIGHT_ATOM_NAME).as_deref() { + Some("all") => TextCombineUpright::All, + _ => TextCombineUpright::None, + } +} + +/// An inherited registered keyword property's computed value, lowercased. +fn inherited_keyword(styles: &ComputedValues, atom_name: &str) -> Option { + styles + .custom_properties() + .inherited + .get(&Atom::from(atom_name)) + .map(|value| value.to_css_string().trim().to_ascii_lowercase()) +} + fn parse_px(token: &str) -> Option { let token = token.trim(); let digits = token.strip_suffix("px").unwrap_or(token); @@ -448,6 +735,54 @@ mod tests { ); } + #[test] + fn prefixed_line_and_word_break_reach_the_standard_properties_as_blink_aliases_them() { + let css = ".a { -webkit-line-break: normal; -epub-line-break: normal } \ + .b { -webkit-line-break: anywhere } \ + .c { -epub-word-break: break-all; -webkit-word-break: keep-all } \ + .d { -epub-text-transform: uppercase; -epub-caption-side: bottom }"; + assert_eq!( + rewrite_stylesheet(css), + ".a { line-break: normal; -epub-line-break: normal } \ + .b { -webkit-line-break: anywhere } \ + .c { word-break: break-all; -webkit-word-break: keep-all } \ + .d { text-transform: uppercase; caption-side: bottom }" + ); + } + + #[test] + fn vertical_text_properties_ride_their_carriers_with_the_browser_keyword() { + let css = ".a { text-orientation: upright } .b { -epub-text-orientation: vertical-right } \ + .c { -webkit-text-orientation: sideways-right } \ + .d { -webkit-text-combine: horizontal; -epub-text-combine: none } \ + .e { text-combine-upright: all !important } .f { text-combine-upright: inherit }"; + assert_eq!( + rewrite_stylesheet(css), + ".a { --rito-internal-text-orientation-v1: upright } \ + .b { --rito-internal-text-orientation-v1: mixed } \ + .c { --rito-internal-text-orientation-v1: sideways } \ + .d { --rito-internal-text-combine-upright-v1: all; \ + --rito-internal-text-combine-upright-v1: none } \ + .e { --rito-internal-text-combine-upright-v1: all !important } \ + .f { --rito-internal-text-combine-upright-v1: inherit }" + ); + } + + #[test] + fn vertical_text_values_another_grammar_owns_keep_their_name() { + // Each spelling accepts only its own grammar, as the browser's + // parser does: the declaration drops instead of carrying. + for css in [ + "text-combine-upright: horizontal", + "-webkit-text-combine: all", + "text-combine-upright: digits 2", + "text-orientation: vertical-right", + "-webkit-text-orientation: mixed", + ] { + assert_eq!(rewrite_declaration_list(css), css); + } + } + #[test] fn does_not_rewrite_names_inside_custom_property_token_blocks() { let css = "p { --tokens: { break-after: page; nested: { break-before: page } }; \ diff --git a/crates/rito-stylo/src/lib.rs b/crates/rito-stylo/src/lib.rs index 45034de72..f29498c8c 100644 --- a/crates/rito-stylo/src/lib.rs +++ b/crates/rito-stylo/src/lib.rs @@ -31,6 +31,7 @@ mod font_faces; mod presentational_hints; mod projection; mod session; +mod text_emphasis; mod traversal; mod ua; mod viewport_dependence; diff --git a/crates/rito-stylo/src/projection/inline/enums.rs b/crates/rito-stylo/src/projection/inline/enums.rs index 470d344ce..84716d586 100644 --- a/crates/rito-stylo/src/projection/inline/enums.rs +++ b/crates/rito-stylo/src/projection/inline/enums.rs @@ -10,10 +10,14 @@ pub(super) fn bidi(styles: &ComputedValues) -> InlineBidi { direction: direction(inherited_box.direction), unicode_bidi: unicode_bidi(styles.get_text().unicode_bidi), writing_mode: writing_mode(inherited_box.writing_mode), + text_orientation: crate::break_properties::project_text_orientation(styles), + text_combine_upright: crate::break_properties::project_text_combine_upright(styles), } } -fn direction(value: style::properties::longhands::direction::computed_value::T) -> Direction { +pub(in crate::projection) fn direction( + value: style::properties::longhands::direction::computed_value::T, +) -> Direction { use style::properties::longhands::direction::computed_value::T; match value { @@ -37,7 +41,9 @@ fn unicode_bidi( } } -fn writing_mode(value: style::logical_geometry::WritingModeProperty) -> WritingMode { +pub(in crate::projection) fn writing_mode( + value: style::logical_geometry::WritingModeProperty, +) -> WritingMode { use style::logical_geometry::WritingModeProperty; match value { diff --git a/crates/rito-stylo/src/projection/inline/mod.rs b/crates/rito-stylo/src/projection/inline/mod.rs index 4ec18c686..7d81ef6d5 100644 --- a/crates/rito-stylo/src/projection/inline/mod.rs +++ b/crates/rito-stylo/src/projection/inline/mod.rs @@ -9,7 +9,7 @@ use style::properties::ComputedValues; use crate::dom::{DomNode, DomStorage}; mod cache; -mod enums; +pub(in crate::projection) mod enums; mod font; mod fragment; mod numeric; @@ -84,6 +84,8 @@ pub enum InlineStyleField { IndividualTranslate, /// This element's own computed text-decoration longhands. TextDecoration, + /// Computed text-emphasis color. + TextEmphasisColor, /// Ordered text-shadow list. TextShadow, /// Ordered box-shadow list. diff --git a/crates/rito-stylo/src/projection/inline/text.rs b/crates/rito-stylo/src/projection/inline/text.rs index fdbf89058..fb2b957ed 100644 --- a/crates/rito-stylo/src/projection/inline/text.rs +++ b/crates/rito-stylo/src/projection/inline/text.rs @@ -1,9 +1,9 @@ -use rito_style_contract::{InlineTextFlow, LanguageTag, TextIndent}; +use rito_style_contract::{InlineTextFlow, LanguageTag, TextEmphasis, TextIndent}; use style::properties::ComputedValues; use crate::dom::DomNode; -use super::{enums, numeric, InlineStyleField, ProjectionResult}; +use super::{enums, numeric, paint, InlineStyleField, ProjectionResult}; pub(super) fn project( styles: &ComputedValues, @@ -34,10 +34,24 @@ pub(super) fn project( each_line: text_indent.each_line, }, ruby_align: crate::break_properties::project_ruby_align(styles), + text_emphasis: text_emphasis(styles)?, language, }) } +/// The computed emphasis marks, read off their carriers. +fn text_emphasis(styles: &ComputedValues) -> ProjectionResult> { + let writing_mode = enums::writing_mode(styles.get_inherited_box().writing_mode); + let Some((mark, side, color)) = crate::text_emphasis::project(styles, writing_mode) else { + return Ok(None); + }; + Ok(Some(TextEmphasis { + mark, + side, + color: paint::computed_color(&color, InlineStyleField::TextEmphasisColor)?, + })) +} + pub(super) fn inherited_language(element: DomNode<'_>) -> Option { element.inherited_language_tag().cloned() } diff --git a/crates/rito-stylo/src/projection/layout.rs b/crates/rito-stylo/src/projection/layout.rs index 9b2fdbd85..a388cffdc 100644 --- a/crates/rito-stylo/src/projection/layout.rs +++ b/crates/rito-stylo/src/projection/layout.rs @@ -5,9 +5,8 @@ use rito_style_contract::{ AlignItems, CellVerticalAlign, Clear, CssPx, Float, JustifyContent, LayoutDisplay, LayoutDisplayInside, LayoutDisplayOutside, LayoutFormattingStyle, LayoutStyleId, LayoutStyleTable, LayoutStyleTableError, LengthPercentage, LengthPercentageOrAuto, - ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, NonNegativeCssPx, - NonNegativeLengthPercentage, NumericError, Overflow, PageBreak, Percentage, PhysicalSides, - Position, PreferredSize, + ListMarkerStyle, MaximumSize, MinimumSize, NonNegativeCssPx, NonNegativeLengthPercentage, + NumericError, Overflow, PageBreak, Percentage, PhysicalSides, Position, PreferredSize, }; use style::{ counter_style::CounterStyle, @@ -25,7 +24,7 @@ use style::{ use crate::{ break_properties::{self, BreakEdge}, - dom::DomStorage, + dom::{DomNode, DomStorage}, }; #[cfg(test)] @@ -44,6 +43,7 @@ pub enum LayoutStyleField { BreakAfter, Width, Height, + MinWidth, MaxWidth, MinHeight, MaxHeight, @@ -176,7 +176,8 @@ pub(crate) fn project_layout( .or_insert_with(|| layout_style(&styles)); match projected { Ok(style) => { - let style_id = table.intern_for_node(node_id.index(), *style)?; + let style = orthogonal_inline_block(*style, element); + let style_id = table.intern_for_node(node_id.index(), style)?; dispositions.push(LayoutStyleDisposition::ContractProjected { node_id, style_id }); } Err(failure) => dispositions.push(rejected(node_id, failure.field, failure.reason)), @@ -188,6 +189,41 @@ pub(crate) fn project_layout( }) } +/// Blink's `StyleAdjuster::AdjustStyleForDisplay`: an inline box whose +/// writing mode is not its layout parent's lays out as an inline-block, a +/// flow of its own across the parent's lines. The layout parent is the +/// nearest ancestor that generates a box. +fn orthogonal_inline_block( + mut style: LayoutFormattingStyle, + element: DomNode<'_>, +) -> LayoutFormattingStyle { + use style::dom::TElement as _; + if style.display.outside != LayoutDisplayOutside::Inline + || style.display.inside != LayoutDisplayInside::Flow + { + return style; + } + let mut parent = element.traversal_parent(); + while let Some(node) = parent { + let Some(styles) = node.primary_styles() else { + break; + }; + if styles.clone_display().inside() + == style::values::specified::box_::DisplayInside::Contents + { + parent = node.traversal_parent(); + continue; + } + let parent_mode = + super::inline::enums::writing_mode(styles.get_inherited_box().writing_mode); + if parent_mode != style.writing_mode { + style.display.inside = LayoutDisplayInside::FlowRoot; + } + break; + } + style +} + /// Projects a generated box's (`::before`, `::after`) computed layout /// style; the error names the field that cannot be projected. pub(crate) fn generated_layout_style( @@ -199,16 +235,20 @@ pub(crate) fn generated_layout_style( fn layout_style(styles: &ComputedValues) -> ProjectionResult { let display = display(styles.clone_display()); validate_flex_flow(styles, display)?; + let inherited_box = styles.get_inherited_box(); Ok(LayoutFormattingStyle { display, + writing_mode: super::inline::enums::writing_mode(inherited_box.writing_mode), + direction: super::inline::enums::direction(inherited_box.direction), justify_content: justify_content(styles.clone_justify_content())?, align_items: align_items(styles.clone_align_items())?, break_before: page_break(styles, BreakEdge::Before, LayoutStyleField::BreakBefore)?, break_after: page_break(styles, BreakEdge::After, LayoutStyleField::BreakAfter)?, width: preferred_size(styles.clone_width(), LayoutStyleField::Width)?, height: preferred_size(styles.clone_height(), LayoutStyleField::Height)?, + min_width: minimum_size(styles.clone_min_width(), LayoutStyleField::MinWidth)?, max_width: maximum_size(styles.clone_max_width())?, - min_height: minimum_height(styles.clone_min_height())?, + min_height: minimum_size(styles.clone_min_height(), LayoutStyleField::MinHeight)?, max_height: maximum_height(styles.clone_max_height())?, clear: clear(styles.clone_clear())?, float: float(styles.clone_float())?, @@ -227,6 +267,9 @@ fn layout_style(styles: &ComputedValues) -> ProjectionResult ProjectionResult { } } -fn minimum_height(value: StyloSize) -> ProjectionResult { - let field = LayoutStyleField::MinHeight; +fn minimum_size(value: StyloSize, field: LayoutStyleField) -> ProjectionResult { match value { GenericSize::LengthPercentage(value) => match height_limit(&value, field)? { - HeightLimit::Length(value) => Ok(MinimumHeight::Length(value)), - HeightLimit::Percentage(value) => Ok(MinimumHeight::Percentage(value)), + HeightLimit::Length(value) => Ok(MinimumSize::Length(value)), + HeightLimit::Percentage(value) => Ok(MinimumSize::Percentage(value)), }, - GenericSize::Auto => Ok(MinimumHeight::Auto), + GenericSize::Auto => Ok(MinimumSize::Auto), GenericSize::MaxContent | GenericSize::MinContent | GenericSize::FitContent @@ -403,14 +445,21 @@ fn minimum_height(value: StyloSize) -> ProjectionResult { } } -fn maximum_height(value: StyloMaxSize) -> ProjectionResult { +/// `max-height` as the same value type `max-width` projects to, holding +/// the subset the block consumer resolves: a length or a percentage. +fn maximum_height(value: StyloMaxSize) -> ProjectionResult { let field = LayoutStyleField::MaxHeight; match value { - GenericMaxSize::LengthPercentage(value) => match height_limit(&value, field)? { - HeightLimit::Length(value) => Ok(MaximumHeight::Length(value)), - HeightLimit::Percentage(value) => Ok(MaximumHeight::Percentage(value)), - }, - GenericMaxSize::None => Ok(MaximumHeight::None), + GenericMaxSize::LengthPercentage(value) => { + let value = match height_limit(&value, field)? { + HeightLimit::Length(value) => LengthPercentage::Length( + CssPx::new(value.get()).map_err(|error| invalid_numeric(field, error))?, + ), + HeightLimit::Percentage(value) => LengthPercentage::Percentage(value), + }; + Ok(MaximumSize::Value(NonNegativeLengthPercentage::new(value))) + } + GenericMaxSize::None => Ok(MaximumSize::None), GenericMaxSize::MaxContent | GenericMaxSize::MinContent | GenericMaxSize::FitContent diff --git a/crates/rito-stylo/src/text_emphasis.rs b/crates/rito-stylo/src/text_emphasis.rs new file mode 100644 index 000000000..4689400cd --- /dev/null +++ b/crates/rito-stylo/src/text_emphasis.rs @@ -0,0 +1,405 @@ +//! `text-emphasis` and its longhands, which stylo's Servo profile does not +//! implement. Each declaration is checked against the browser's grammar +//! and rides a registered custom property: the style and the position as +//! one canonical keyword, the color as written. The shorthand splits into +//! its two longhands' carriers. A value the grammar rejects keeps its own +//! name and drops at parse time, as the browser drops it. + +use std::sync::Arc; + +use cssparser::{Parser, ParserInput, Token}; +use rito_style_contract::{LineSide, WritingMode}; +use style::{ + context::QuirksMode, + properties::{parse_style_attribute, ComputedValues, PropertyDeclaration}, + stylesheets::{CssRuleType, UrlExtraData}, + values::computed, + Atom, +}; +use style_traits::ToCss; + +pub(crate) const STYLE_CSS_NAME: &str = "--rito-internal-text-emphasis-style-v1"; +pub(crate) const COLOR_CSS_NAME: &str = "--rito-internal-text-emphasis-color-v1"; +pub(crate) const POSITION_CSS_NAME: &str = "--rito-internal-text-emphasis-position-v1"; +const STYLE_ATOM_NAME: &str = "rito-internal-text-emphasis-style-v1"; +const COLOR_ATOM_NAME: &str = "rito-internal-text-emphasis-color-v1"; +const POSITION_ATOM_NAME: &str = "rito-internal-text-emphasis-position-v1"; + +/// The declarations one emphasis declaration becomes: each carrier with +/// the value it carries. +pub(crate) type Carried = Vec<(&'static str, String)>; + +const CSS_WIDE: [&str; 5] = ["inherit", "initial", "unset", "revert", "revert-layer"]; +const FILLS: [&str; 2] = ["filled", "open"]; +const SHAPES: [&str; 5] = ["dot", "circle", "double-circle", "triangle", "sesame"]; + +/// One top-level component of a declaration value: what it is, and its +/// source text. +#[derive(Clone, Debug, PartialEq)] +enum Component<'a> { + Ident(String, &'a str), + String(&'a str), + Other(&'a str), +} + +impl<'a> Component<'a> { + fn source(&self) -> &'a str { + match self { + Self::Ident(_, source) | Self::String(source) | Self::Other(source) => source, + } + } + + fn keyword(&self) -> Option<&str> { + match self { + Self::Ident(keyword, _) => Some(keyword), + _ => None, + } + } +} + +/// The value's top-level components, comments and white space skipped, or +/// none when it does not tokenize. +fn components(value: &str) -> Option>> { + let mut input = ParserInput::new(value); + let mut parser = Parser::new(&mut input); + let mut out = Vec::new(); + loop { + parser.skip_whitespace(); + let start = parser.position(); + let token = match parser.next() { + Ok(token) => token.clone(), + Err(_) => break, + }; + let nested = matches!( + token, + Token::Function(_) + | Token::ParenthesisBlock + | Token::SquareBracketBlock + | Token::CurlyBracketBlock + ); + if nested { + parser + .parse_nested_block(|block| { + while block.next().is_ok() {} + Ok::<_, cssparser::ParseError<'_, ()>>(()) + }) + .ok()?; + } + let source = parser.slice_from(start).trim_end(); + out.push(match token { + Token::Ident(ident) => Component::Ident(ident.to_ascii_lowercase(), source), + Token::QuotedString(_) => Component::String(source), + Token::BadString(_) | Token::BadUrl(_) => return None, + _ => Component::Other(source), + }); + } + Some(out) +} + +/// A CSS-wide keyword value, which every longhand takes as it is. +fn css_wide(components: &[Component<'_>]) -> Option<&'static str> { + let [component] = components else { + return None; + }; + let keyword = component.keyword()?; + CSS_WIDE.iter().copied().find(|wide| *wide == keyword) +} + +/// `text-emphasis-style`'s canonical keyword for `components`: `none`, a +/// string as written, or `-` with the shape `auto` when only +/// the fill is given and the fill `filled` when only the shape is. +fn style_keyword(components: &[Component<'_>]) -> Option { + if let [Component::String(source)] = components { + return Some((*source).to_owned()); + } + if let [Component::Ident(keyword, _)] = components { + if keyword == "none" { + return Some("none".to_owned()); + } + } + let mut fill = None; + let mut shape = None; + if components.is_empty() || components.len() > 2 { + return None; + } + for component in components { + let keyword = component.keyword()?; + if let Some(found) = FILLS.iter().find(|fill| **fill == keyword) { + if fill.replace(*found).is_some() { + return None; + } + } else if let Some(found) = SHAPES.iter().find(|shape| **shape == keyword) { + if shape.replace(*found).is_some() { + return None; + } + } else { + return None; + } + } + Some(format!( + "{}-{}", + fill.unwrap_or("filled"), + shape.unwrap_or("auto") + )) +} + +/// Whether `value` is one `` the browser's parser accepts. +fn parse_color(value: &str) -> Option { + if value.contains([';', '!', '{', '}']) { + return None; + } + let url_data = UrlExtraData::from(url::Url::parse("about:blank").ok()?); + let block = parse_style_attribute( + &format!("color:{value}"), + &url_data, + None, + QuirksMode::NoQuirks, + CssRuleType::Style, + ); + let mut declarations = block.declarations().iter(); + let declaration = declarations.next()?; + if declarations.next().is_some() { + return None; + } + match declaration { + PropertyDeclaration::Color(color) => color.0.to_computed_color(None).ok(), + _ => None, + } +} + +/// `text-emphasis-style: ` as its carrier declaration. +pub(crate) fn carry_style(value: &str) -> Option { + let components = components(value)?; + let carried = match css_wide(&components) { + Some(wide) => wide.to_owned(), + None => style_keyword(&components)?, + }; + Some(vec![(STYLE_CSS_NAME, carried)]) +} + +/// `text-emphasis-color: ` as its carrier declaration. +pub(crate) fn carry_color(value: &str) -> Option { + let components = components(value)?; + if let Some(wide) = css_wide(&components) { + return Some(vec![(COLOR_CSS_NAME, wide.to_owned())]); + } + parse_color(value)?; + Some(vec![(COLOR_CSS_NAME, value.trim().to_owned())]) +} + +/// `text-emphasis-position: ` as its carrier declaration: one of +/// `over` and `under`, and optionally one of `right` (the initial) and +/// `left`, in either order. +pub(crate) fn carry_position(value: &str) -> Option { + let components = components(value)?; + if let Some(wide) = css_wide(&components) { + return Some(vec![(POSITION_CSS_NAME, wide.to_owned())]); + } + let mut over = None; + let mut right = None; + if components.is_empty() || components.len() > 2 { + return None; + } + for component in &components { + let (slot, value) = match component.keyword()? { + "over" => (&mut over, true), + "under" => (&mut over, false), + "right" => (&mut right, true), + "left" => (&mut right, false), + _ => return None, + }; + if slot.replace(value).is_some() { + return None; + } + } + let over = over?; + let right = right.unwrap_or(true); + let keyword = match (over, right) { + (true, true) => "over-right", + (true, false) => "over-left", + (false, true) => "under-right", + (false, false) => "under-left", + }; + Some(vec![(POSITION_CSS_NAME, keyword.to_owned())]) +} + +/// `text-emphasis: ` as its two longhands' carrier declarations: +/// a style (one string, `none`, or its fill and shape keywords together) +/// and a color, each at most once and in either order, the one left out +/// taking its initial. +pub(crate) fn carry_shorthand(value: &str) -> Option { + let components = components(value)?; + if let Some(wide) = css_wide(&components) { + return Some(vec![ + (STYLE_CSS_NAME, wide.to_owned()), + (COLOR_CSS_NAME, wide.to_owned()), + ]); + } + let is_style = |component: &Component<'_>| match component { + Component::String(_) => true, + Component::Ident(keyword, _) => { + keyword == "none" + || FILLS.contains(&keyword.as_str()) + || SHAPES.contains(&keyword.as_str()) + } + Component::Other(_) => false, + }; + // The style is one run of style components at either end; the color + // is everything else. + let leading = components.iter().take_while(|c| is_style(c)).count(); + let trailing = components.iter().rev().take_while(|c| is_style(c)).count(); + let (style, color) = if leading == components.len() { + (&components[..], &components[..0]) + } else if leading > 0 { + (&components[..leading], &components[leading..]) + } else { + let split = components.len() - trailing; + (&components[split..], &components[..split]) + }; + if color.iter().any(is_style) { + return None; + } + let style = if style.is_empty() { + "none".to_owned() + } else { + style_keyword(style)? + }; + let color = match (color.first(), color.last()) { + (Some(first), Some(last)) => { + let start = first.source().as_ptr() as usize - value.as_ptr() as usize; + let end = + last.source().as_ptr() as usize - value.as_ptr() as usize + last.source().len(); + let text = &value[start..end]; + parse_color(text)?; + text.to_owned() + } + _ => "currentcolor".to_owned(), + }; + Some(vec![(STYLE_CSS_NAME, style), (COLOR_CSS_NAME, color)]) +} + +/// The computed emphasis an element's text takes, read off the carriers: +/// its mark (a keyword's character, `auto` resolving by typographic +/// mode, or the custom string), the line side it stands on, and its +/// color. `None` for `none`. +pub(crate) fn project( + styles: &ComputedValues, + writing_mode: WritingMode, +) -> Option<(Arc, LineSide, computed::Color)> { + let read = |atom: &str| { + styles + .custom_properties() + .inherited + .get(&Atom::from(atom)) + .map(|value| value.to_css_string().trim().to_owned()) + }; + let style = read(STYLE_ATOM_NAME)?; + let vertical = writing_mode != WritingMode::HorizontalTopToBottom; + let mark: Arc = if style.starts_with(['"', '\'']) { + let mut input = ParserInput::new(&style); + let mut parser = Parser::new(&mut input); + let string = parser.expect_string().ok()?.to_string(); + if string.is_empty() { + return None; + } + Arc::from(string) + } else { + let (fill, shape) = style.split_once('-')?; + let filled = fill == "filled"; + let shape = match shape { + "auto" if vertical => "sesame", + "auto" => "dot", + shape => shape, + }; + let mark = match (shape, filled) { + ("dot", true) => '\u{2022}', + ("dot", false) => '\u{25E6}', + ("circle", true) => '\u{25CF}', + ("circle", false) => '\u{25CB}', + ("double-circle", true) => '\u{25C9}', + ("double-circle", false) => '\u{25CE}', + ("triangle", true) => '\u{25B2}', + ("triangle", false) => '\u{25B3}', + ("sesame", true) => '\u{FE45}', + ("sesame", false) => '\u{FE46}', + _ => return None, + }; + Arc::from(mark.to_string()) + }; + // A horizontal line's marks stand over or under it; a vertical one's + // right (its line-over side) or left. + let position = read(POSITION_ATOM_NAME).unwrap_or_else(|| "over-right".to_owned()); + let side = match (vertical, position.as_str()) { + (false, "under-right" | "under-left") | (true, "over-left" | "under-left") => { + LineSide::Under + } + _ => LineSide::Over, + }; + let color = read(COLOR_ATOM_NAME) + .and_then(|color| parse_color(&color)) + .unwrap_or(computed::Color::CurrentColor); + Some((mark, side, color)) +} + +#[cfg(test)] +mod tests { + use super::{carry_color, carry_position, carry_shorthand, carry_style}; + + fn values(carried: Option) -> Vec { + carried + .unwrap_or_default() + .into_iter() + .map(|(_, value)| value) + .collect() + } + + #[test] + fn a_style_carries_one_canonical_keyword() { + assert_eq!(values(carry_style("filled sesame")), ["filled-sesame"]); + assert_eq!(values(carry_style(" SESAME /*x*/ open ")), ["open-sesame"]); + assert_eq!(values(carry_style("dot")), ["filled-dot"]); + assert_eq!(values(carry_style("open")), ["open-auto"]); + assert_eq!(values(carry_style("none")), ["none"]); + assert_eq!(values(carry_style("'*'")), ["'*'"]); + assert_eq!(values(carry_style("inherit")), ["inherit"]); + for invalid in ["filled open", "dot circle", "filled none", "", "auto", "x"] { + assert_eq!(carry_style(invalid), None, "{invalid}"); + } + } + + #[test] + fn a_position_carries_its_side_and_column_side() { + assert_eq!(values(carry_position("under")), ["under-right"]); + assert_eq!(values(carry_position("left over")), ["over-left"]); + assert_eq!(values(carry_position("over right")), ["over-right"]); + for invalid in ["right", "over under", "under left left", "auto"] { + assert_eq!(carry_position(invalid), None, "{invalid}"); + } + } + + #[test] + fn a_color_carries_as_written_when_it_parses() { + assert_eq!(values(carry_color("#000000")), ["#000000"]); + assert_eq!(values(carry_color("rgb(1, 2, 3)")), ["rgb(1, 2, 3)"]); + assert_eq!(carry_color("nocolor"), None); + } + + #[test] + fn the_shorthand_splits_into_style_and_color() { + assert_eq!( + values(carry_shorthand("circle #000")), + ["filled-circle", "#000"] + ); + assert_eq!( + values(carry_shorthand("rgb(0, 0, 0) open dot")), + ["open-dot", "rgb(0, 0, 0)"] + ); + assert_eq!( + values(carry_shorthand("filled dot")), + ["filled-dot", "currentcolor"] + ); + assert_eq!(values(carry_shorthand("red")), ["none", "red"]); + assert_eq!(carry_shorthand("filled red dot"), None); + assert_eq!(carry_shorthand("dot circle"), None); + } +} diff --git a/crates/rito-stylo/tests/inline_style.rs b/crates/rito-stylo/tests/inline_style.rs index 23cc48ee4..d2eb41a95 100644 --- a/crates/rito-stylo/tests/inline_style.rs +++ b/crates/rito-stylo/tests/inline_style.rs @@ -2,8 +2,10 @@ use std::sync::Arc; use rito_source::{NodeId, SourceArena, SourceNodeKind}; use rito_style_contract::{ - BackgroundImageRepeat, BackgroundImageSize, FontFamily, FontFamilyNameSyntax, - GenericFontFamily, LengthPercentage, RubyAlign, TransformOperation, RESOLVED_URL_BYTE_LIMIT, + AbsoluteColor, AbsoluteColorSpace, BackgroundImageRepeat, BackgroundImageSize, ColorNoneFlags, + ComputedColor, FontFamily, FontFamilyNameSyntax, GenericFontFamily, LengthPercentage, LineSide, + RubyAlign, TextCombineUpright, TextOrientation, TransformOperation, WritingMode, + RESOLVED_URL_BYTE_LIMIT, }; use rito_stylo::{ InlineStyleDisposition, InlineStyleField, InlineStyleProjection, InlineStyleProjectionReason, @@ -516,6 +518,116 @@ fn ruby_align_cascades_through_the_registered_custom_property() { assert_eq!(ruby_align(&projection, plain_rt), RubyAlign::SpaceAround); } +#[test] +fn vertical_text_properties_cascade_through_their_carriers() { + let source = source( + r#"

a12b

"#, + ); + let p = source.find_element_by_id("p").unwrap(); + let tcy = source.find_element_by_id("tcy").unwrap(); + let up = source.find_element_by_id("up").unwrap(); + let mut document = document( + Arc::clone(&source), + ".vrtl { -webkit-writing-mode: vertical-rl } \ + .tcy { -webkit-text-combine: horizontal; text-combine-upright: horizontal } \ + .upright { -epub-text-orientation: upright }", + ); + + let projection = inline_projection(&mut document); + let bidi = |node: NodeId| { + projection + .table() + .style_for_node(node.index()) + .unwrap() + .bidi + }; + assert_eq!(bidi(p).writing_mode, WritingMode::VerticalRightToLeft); + assert_eq!(bidi(p).text_orientation, TextOrientation::Mixed); + assert_eq!(bidi(p).text_combine_upright, TextCombineUpright::None); + // The standard property rejects `horizontal`, so the prefixed + // declaration before it stands. + assert_eq!(bidi(tcy).text_combine_upright, TextCombineUpright::All); + assert_eq!(bidi(up).text_orientation, TextOrientation::Upright); +} + +#[test] +fn text_emphasis_cascades_through_its_carriers() { + let source = source( + r#"

abcde

fg

"#, + ); + let node = |id: &str| source.find_element_by_id(id).unwrap(); + let mut document = document( + Arc::clone(&source), + ".dot { -webkit-text-emphasis-style: filled dot; -webkit-text-emphasis-position: under; \ + -epub-text-emphasis-style: filled circle; -epub-text-emphasis-position: over; \ + text-emphasis: circle #ff0000; text-emphasis-position: under } \ + .sesame { -webkit-text-emphasis-style: open sesame; -epub-text-emphasis-style: bogus } \ + .custom { text-emphasis-style: '*'; text-emphasis-color: red } \ + .v { writing-mode: vertical-rl } \ + .auto { -epub-text-emphasis: open } \ + .left { -webkit-text-emphasis: dot; text-emphasis-position: over left }", + ); + + let projection = inline_projection(&mut document); + let emphasis = |id: &str| { + projection + .table() + .style_for_node(node(id).index()) + .unwrap() + .text_flow + .text_emphasis + .clone() + .map(|emphasis| (emphasis.mark.to_string(), emphasis.side, emphasis.color)) + }; + let red = ComputedColor::Absolute( + AbsoluteColor::new( + AbsoluteColorSpace::Srgb, + [1.0, 0.0, 0.0], + 1.0, + ColorNoneFlags::new(false, false, false, false), + ) + .unwrap(), + ); + assert_eq!(emphasis("p"), None); + // The last valid spelling wins; `-epub-text-emphasis-position` is no + // property of the browser's and drops. + assert_eq!( + emphasis("dot"), + Some(("\u{25CF}".to_owned(), LineSide::Under, red)) + ); + assert_eq!(emphasis("inner"), emphasis("dot")); + assert_eq!( + emphasis("sesame"), + Some(( + "\u{FE46}".to_owned(), + LineSide::Over, + ComputedColor::CurrentColor + )) + ); + assert_eq!( + emphasis("custom"), + Some(("*".to_owned(), LineSide::Over, red)) + ); + // A vertical line's `auto` shape is the sesame, and its marks stand + // on the right unless the position says left. + assert_eq!( + emphasis("auto"), + Some(( + "\u{FE46}".to_owned(), + LineSide::Over, + ComputedColor::CurrentColor + )) + ); + assert_eq!( + emphasis("left"), + Some(( + "\u{2022}".to_owned(), + LineSide::Under, + ComputedColor::CurrentColor + )) + ); +} + fn ruby_align(projection: &rito_stylo::InlineStyleProjection, node_id: NodeId) -> RubyAlign { projection .table() diff --git a/crates/rito-stylo/tests/layout_style.rs b/crates/rito-stylo/tests/layout_style.rs index 201bdbe1d..c7a30ed3f 100644 --- a/crates/rito-stylo/tests/layout_style.rs +++ b/crates/rito-stylo/tests/layout_style.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use rito_source::SourceArena; use rito_style_contract::{ AlignItems, Clear, Float, JustifyContent, LayoutDisplayInside, LayoutDisplayOutside, - LengthPercentage, ListMarkerStyle, MaximumHeight, MaximumSize, MinimumHeight, NonNegativeCssPx, + LengthPercentage, ListMarkerStyle, MaximumSize, MinimumSize, NonNegativeCssPx, NonNegativeLengthPercentage, Overflow, PageBreak, Percentage, PreferredSize, }; use rito_stylo::{ @@ -110,11 +110,13 @@ fn height_constraints_float_and_overflow_project_exactly() { .unwrap(); assert_eq!( style.min_height, - MinimumHeight::Length(NonNegativeCssPx::new(12.0).unwrap()) + MinimumSize::Length(NonNegativeCssPx::new(12.0).unwrap()) ); assert_eq!( style.max_height, - MaximumHeight::Length(NonNegativeCssPx::new(120.0).unwrap()) + MaximumSize::Value(NonNegativeLengthPercentage::new(LengthPercentage::Length( + rito_style_contract::CssPx::new(120.0).unwrap() + ))) ); assert_eq!(style.float, Float::Left); assert_eq!(style.overflow, Overflow::Hidden); @@ -298,11 +300,13 @@ fn percentage_height_constraints_are_preserved_in_the_contract() { .unwrap(); assert_eq!( style.min_height, - MinimumHeight::Percentage(Percentage::from_percent(25.0).unwrap()) + MinimumSize::Percentage(Percentage::from_percent(25.0).unwrap()) ); assert_eq!( style.max_height, - MaximumHeight::Percentage(Percentage::from_percent(100.0).unwrap()) + MaximumSize::Value(NonNegativeLengthPercentage::new( + LengthPercentage::Percentage(Percentage::from_percent(100.0).unwrap()) + )) ); } diff --git a/crates/vendor/parley/src/analysis/mod.rs b/crates/vendor/parley/src/analysis/mod.rs index f21d24bd6..3f3b6beb7 100644 --- a/crates/vendor/parley/src/analysis/mod.rs +++ b/crates/vendor/parley/src/analysis/mod.rs @@ -411,6 +411,8 @@ pub(crate) fn analyze_text( // Merge boundaries - line takes precedence over word let mut lb_iter = line_boundary_positions.iter().peekable(); + // Rito: the unbreakable ranges, consumed as the text passes them. + let mut unbreakable = lcx.unbreakable.iter().peekable(); let mut prev_char = None; let mut prev_prev_char = None; let boundary_iter = text.char_indices().map(|(byte_pos, ch)| { @@ -452,6 +454,7 @@ pub(crate) fn analyze_text( before_before: prev_prev_char, before: prev, after: ch, + index: byte_pos, }); if let Some(forced) = forced { is_line = forced; @@ -460,6 +463,16 @@ pub(crate) fn analyze_text( prev_prev_char = prev_char; prev_char = Some(ch); + // Rito: no soft wrap opportunity strictly inside an unbreakable + // range. + while unbreakable.next_if(|range| range.end <= byte_pos).is_some() {} + if unbreakable + .peek() + .is_some_and(|range| range.start < byte_pos) + { + is_line = false; + } + let boundary = if is_line { Boundary::Line } else if is_word { diff --git a/crates/vendor/parley/src/break_overrides.rs b/crates/vendor/parley/src/break_overrides.rs index 01577f491..9c26c321a 100644 --- a/crates/vendor/parley/src/break_overrides.rs +++ b/crates/vendor/parley/src/break_overrides.rs @@ -15,6 +15,8 @@ pub struct LineBreakContext { pub before: char, /// The code point after the potential break. pub after: char, + /// Rito: the byte offset of `after` in the text being broken. + pub index: usize, } /// Line break opportunity override. @@ -323,6 +325,7 @@ mod tests { before_before, before, after, + index: 0, } } diff --git a/crates/vendor/parley/src/builder.rs b/crates/vendor/parley/src/builder.rs index 463040702..ffc2f7f86 100644 --- a/crates/vendor/parley/src/builder.rs +++ b/crates/vendor/parley/src/builder.rs @@ -52,6 +52,13 @@ impl<'b, B: Brush> RangedBuilder<'b, B> { self.lcx.inline_boxes.push(inline_box); } + /// Rito: removes every soft wrap opportunity strictly inside `range` + /// (a break before its start and after its end stays). Ranges are + /// pushed in text order. + pub fn push_unbreakable(&mut self, range: Range) { + self.lcx.unbreakable.push(range); + } + /// Set the callback which will be called as a first provider of line breaking decisions. /// /// See [`LineBreakOverrideFn`] for more details. diff --git a/crates/vendor/parley/src/context.rs b/crates/vendor/parley/src/context.rs index 277e2d285..07c2e1884 100644 --- a/crates/vendor/parley/src/context.rs +++ b/crates/vendor/parley/src/context.rs @@ -37,6 +37,10 @@ pub struct LayoutContext { // Unicode analysis data sources (provided by icu) pub(crate) analysis_data_sources: AnalysisDataSources, + + /// Rito: text ranges with no soft wrap opportunity strictly inside + /// them, in text order. + pub(crate) unbreakable: Vec>, } impl LayoutContext { @@ -52,6 +56,7 @@ impl LayoutContext { info: vec![], analysis_data_sources: AnalysisDataSources::new(), scx: ShapeContext::default(), + unbreakable: vec![], } } @@ -187,6 +192,7 @@ impl LayoutContext { self.inline_boxes.clear(); self.info.clear(); self.bidi.clear(); + self.unbreakable.clear(); } } diff --git a/crates/vendor/parley/src/inline_box.rs b/crates/vendor/parley/src/inline_box.rs index e7324bda6..fb7835a26 100644 --- a/crates/vendor/parley/src/inline_box.rs +++ b/crates/vendor/parley/src/inline_box.rs @@ -37,4 +37,24 @@ pub enum InlineBoxKind { /// /// They can be used to implement advanced layout modes such as CSS's `float` CustomOutOfFlow, + /// Rito: the inline-start margin, border and padding of an inline box + /// opening at this index (Blink's open tag item). It takes its width on + /// the line but offers no break of its own: a break the text allows at + /// its index is taken before it, so it opens the next line together + /// with what follows. A forced break leaves it on the line it ends. + OpenTag, + /// Rito: the inline-end margin, border and padding of an inline box + /// closing at this index (Blink's close tag item). It takes its width + /// on the line and stays with the content before it: it never opens a + /// line, and when it overflows, the line breaks at the last opportunity + /// before that content. + CloseTag, +} + +impl InlineBoxKind { + /// Rito: whether the box is an inline box's start or end size, which + /// takes width on its line but no height and no content of its own. + pub fn is_tag(self) -> bool { + matches!(self, Self::OpenTag | Self::CloseTag) + } } diff --git a/crates/vendor/parley/src/layout/cluster.rs b/crates/vendor/parley/src/layout/cluster.rs index b96107dc1..25fee3426 100644 --- a/crates/vendor/parley/src/layout/cluster.rs +++ b/crates/vendor/parley/src/layout/cluster.rs @@ -174,6 +174,15 @@ impl<'a, B: Brush> Cluster<'a, B> { self.data.advance } + /// Rito: how much wider this cluster shapes alone than in context where + /// a line ends after it (the shaper marked the break after it unsafe), + /// as Blink reshapes a line's end; zero where breaking after it is safe. + pub fn line_end_delta(&self) -> f32 { + // The path counts from the run's clusters on this line. + let index = self.run.cluster_range().start + self.path.logical_index as usize; + self.run.layout.data.line_end_delta(index) + } + /// Returns `true` if this is a right-to-left cluster. pub fn is_rtl(&self) -> bool { self.run.is_rtl() @@ -189,6 +198,12 @@ impl<'a, B: Brush> Cluster<'a, B> { self.data.is_ligature_component() } + /// Rito: whether the cluster is part of a combined upright unit (see + /// [`Layout::combine_upright`](crate::Layout::combine_upright)). + pub fn is_combined(&self) -> bool { + self.data.is_combined() + } + /// Returns `true` if the cluster is a word boundary. pub fn is_word_boundary(&self) -> bool { self.data.info.is_boundary() diff --git a/crates/vendor/parley/src/layout/data.rs b/crates/vendor/parley/src/layout/data.rs index bc971aa1e..80f49d56e 100644 --- a/crates/vendor/parley/src/layout/data.rs +++ b/crates/vendor/parley/src/layout/data.rs @@ -30,7 +30,9 @@ pub(crate) struct ClusterData { /// taken from the owning run. pub(crate) glyph_offset: u32, /// Offset into the text for this cluster - pub(crate) text_offset: u16, + // Rito: 32 bits; a shaping run longer than 64 KiB (a volume written as + // one paragraph in one style) wrapped a 16-bit offset. + pub(crate) text_offset: u32, /// Advance width for this cluster pub(crate) advance: f32, } @@ -38,6 +40,9 @@ pub(crate) struct ClusterData { impl ClusterData { pub(crate) const LIGATURE_START: u16 = 1; pub(crate) const LIGATURE_COMPONENT: u16 = 2; + /// Rito: part of a combined upright unit (see + /// [`Layout::combine_upright`](crate::Layout::combine_upright)). + pub(crate) const COMBINED: u16 = 4; #[inline(always)] pub(crate) fn is_ligature_start(self) -> bool { @@ -49,6 +54,11 @@ impl ClusterData { self.flags & Self::LIGATURE_COMPONENT != 0 } + #[inline(always)] + pub(crate) fn is_combined(self) -> bool { + self.flags & Self::COMBINED != 0 + } + #[inline(always)] pub(crate) fn text_range(self, run: &RunData) -> Range { let start = run.text_range.start + self.text_offset as usize; @@ -147,6 +157,9 @@ pub(crate) struct RunData { /// Rito: the run's entries in [`LayoutData::style_breaks`], set once /// the layout is built. pub(crate) style_breaks: Range, + /// Rito: whether the run was shaped top to bottom (upright in a + /// vertical line). + pub(crate) vertical_upright: bool, } #[derive(Copy, Clone, Default, PartialEq, Debug)] @@ -303,6 +316,13 @@ pub(crate) struct LayoutData { /// in logical order: where a line splits a run by style (see /// `Line::style_runs`), found without reading the clusters between. pub(crate) style_breaks: Arc>, + /// Rito: for each cluster whose end is a possible line end the shaper + /// marked unsafe to break, by index in `clusters` ascending, how much + /// wider the cluster shapes alone than in context. Blink reshapes such + /// a line end without the text after it (`ShapingLineBreaker::ShapeLine`'s + /// `reshape_line_end`), so the line's last glyph loses its kerning with + /// the space that hangs after it. + pub(crate) line_end_deltas: Arc>, // Output of line breaking /// The lines in the @@ -350,6 +370,7 @@ impl Default for LayoutData { clusters: Arc::default(), glyphs: Arc::default(), style_breaks: Arc::default(), + line_end_deltas: Arc::default(), lines: Vec::new(), line_items: Vec::new(), #[cfg(feature = "accesskit")] @@ -380,10 +401,19 @@ impl LayoutData { vacate(&mut self.clusters); vacate(&mut self.glyphs); vacate(&mut self.style_breaks); + vacate(&mut self.line_end_deltas); self.lines.clear(); self.line_items.clear(); } + /// Rito: how much wider cluster `index` shapes alone at a line's end + /// than in context (see `line_end_deltas`); zero where it is safe. + pub(crate) fn line_end_delta(&self, index: usize) -> f32 { + self.line_end_deltas + .binary_search_by_key(&(index as u32), |(cluster, _)| *cluster) + .map_or(0.0, |found| self.line_end_deltas[found].1) + } + /// Push an inline box to the list of items pub(crate) fn push_inline_box(&mut self, index: usize) { // Give the box the same bidi level as the preceding text run @@ -408,6 +438,7 @@ impl LayoutData { style_index: u16, word_spacing: f32, letter_spacing: f32, + vertical_upright: bool, source_text: &str, char_infos: &[(CharInfo, u16)], // From text analysis text_range: Range, // The text range this run covers @@ -427,10 +458,17 @@ impl LayoutData { index }); - let metrics = { + let (metrics, vertical_fallback) = { let font = &self.fonts[font_index]; let font_ref = skrifa::FontRef::from_index(font.data.as_ref(), font.index).unwrap(); - skrifa::metrics::Metrics::new(&font_ref, skrifa::prelude::Size::new(font_size), coords) + ( + skrifa::metrics::Metrics::new( + &font_ref, + skrifa::prelude::Size::new(font_size), + coords, + ), + vertical_upright.then(|| vertical_fallback(&font_ref, font_size)), + ) }; let units_per_em = metrics.units_per_em as f32; @@ -491,6 +529,7 @@ impl LayoutData { letter_spacing, advance: 0., style_breaks: 0..0, + vertical_upright, }; // `HarfRust` returns glyphs in visual order, so we need to process them as such while @@ -510,6 +549,8 @@ impl LayoutData { owned(&mut self.clusters), owned(&mut self.glyphs), scale_factor, + vertical_upright, + vertical_fallback.flatten(), glyph_infos, glyph_positions, char_infos, @@ -521,6 +562,8 @@ impl LayoutData { owned(&mut self.clusters), owned(&mut self.glyphs), scale_factor, + vertical_upright, + vertical_fallback.flatten(), glyph_infos, glyph_positions, char_infos, @@ -603,8 +646,19 @@ impl LayoutData { let mut running_min_width = 0.0; let mut running_max_width = 0.0; + // Rito: open tags waiting for the content they open. + let mut pending_open_tags = 0.0_f32; let mut text_wrap_mode = TextWrapMode::Wrap; let mut prev_cluster: Option<&ClusterData> = None; + // Rito: a forced break's mandatory boundary lands on the next + // cluster, so an inline box (or the open tag before one) right + // after the newline starts the next line here instead — the + // browser sizes `a
` by the wider of the two, not + // their sum. Close tags stay on the line the break ends. + let mut after_newline = false; + // The break taken there is the one the next cluster's mandatory + // boundary marks: it must not end that box's line again. + let mut break_taken = false; let is_rtl = self.base_level & 1 == 1; for item in self.items.iter() { match item.kind { @@ -615,10 +669,15 @@ impl LayoutData { prev_cluster = clusters.first(); } for cluster in clusters { - let boundary = cluster.info.boundary(); + let boundary = match cluster.info.boundary() { + Boundary::Mandatory if break_taken => Boundary::None, + boundary => boundary, + }; + break_taken = false; let style = &self.styles[cluster.style_index as usize]; let prev_text_wrap_mode = text_wrap_mode; text_wrap_mode = style.text_wrap_mode; + after_newline = cluster.info.whitespace() == Whitespace::Newline; if boundary == Boundary::Mandatory || (prev_text_wrap_mode == TextWrapMode::Wrap && (boundary == Boundary::Line @@ -632,7 +691,8 @@ impl LayoutData { running_max_width = 0.0; } } - running_min_width += cluster.advance; + running_min_width += pending_open_tags + cluster.advance; + pending_open_tags = 0.0; running_max_width += cluster.advance; if !is_rtl { // Rito: a newline cluster must not shadow the @@ -650,6 +710,31 @@ impl LayoutData { } LayoutItemKind::InlineBox => { let ibox = &self.inline_boxes[item.index]; + if after_newline && ibox.kind != InlineBoxKind::CloseTag { + let trailing_whitespace = whitespace_advance(prev_cluster); + min_width = min_width.max(running_min_width - trailing_whitespace); + max_width = max_width.max(running_max_width - trailing_whitespace); + running_min_width = 0.0; + running_max_width = 0.0; + prev_cluster = None; + after_newline = false; + break_taken = true; + } + // Rito: an inline box's start size rides with the content + // after it (a break before that content takes it along), + // its end size with the content before it. + if ibox.kind == InlineBoxKind::OpenTag { + running_max_width += ibox.width; + pending_open_tags += ibox.width; + continue; + } + if ibox.kind == InlineBoxKind::CloseTag { + running_max_width += ibox.width; + running_min_width += ibox.width; + let trailing_whitespace = whitespace_advance(prev_cluster); + max_width = max_width.max(running_max_width - trailing_whitespace); + continue; + } if ibox.kind == InlineBoxKind::InFlow { running_max_width += ibox.width; if text_wrap_mode == TextWrapMode::Wrap { @@ -669,7 +754,7 @@ impl LayoutData { } let trailing_whitespace = whitespace_advance(prev_cluster); - min_width = min_width.max(running_min_width - trailing_whitespace); + min_width = min_width.max(running_min_width + pending_open_tags - trailing_whitespace); ContentWidths { min: min_width, @@ -678,6 +763,51 @@ impl LayoutData { } } +/// Rito: the vertical advance at `font_size` of an upright glyph in a face +/// without vertical metrics (no `vhea` and `vmtx`), and the shaper's default +/// it stands in for, in font units: Blink advances such a glyph by the +/// face's height (`OpenTypeVerticalData::AdvanceHeight`, its +/// `height_fallback_`), its ascent and descent each rounded to whole pixels +/// and summed — the hhea metrics, or the OS/2 typo ones when the face sets +/// USE_TYPO_METRICS, as Skia reports them — where the shaper takes the typo +/// ascent less descent. `None` for a face with vertical metrics. +fn vertical_fallback(font_ref: &skrifa::FontRef<'_>, font_size: f32) -> Option<(i32, f32)> { + use skrifa::raw::TableProvider as _; + if font_ref.vhea().is_ok() && font_ref.vmtx().is_ok() { + return None; + } + let scale = f64::from(font_size) / f64::from(font_ref.head().ok()?.units_per_em().max(1)); + let os2 = font_ref.os2().ok(); + let hhea = font_ref.hhea().ok(); + let shaper_default = match (&os2, &hhea) { + (Some(os2), _) => i32::from(os2.s_typo_ascender()) - i32::from(os2.s_typo_descender()), + (None, Some(hhea)) => { + i32::from(hhea.ascender().to_i16()) - i32::from(hhea.descender().to_i16()) + } + (None, None) => 0, + }; + let use_typo = os2.as_ref().is_some_and(|os2| { + os2.fs_selection() + .contains(skrifa::raw::tables::os2::SelectionFlags::USE_TYPO_METRICS) + }); + let (ascent, descent) = match (use_typo, &os2, &hhea) { + (true, Some(os2), _) => ( + f64::from(os2.s_typo_ascender()), + -f64::from(os2.s_typo_descender()), + ), + (_, _, Some(hhea)) => ( + f64::from(hhea.ascender().to_i16()), + -f64::from(hhea.descender().to_i16()), + ), + _ => return None, + }; + let round = |value: f64| (value + 0.5).floor(); + Some(( + shaper_default, + (round(ascent * scale) + round(descent * scale)) as f32, + )) +} + /// Processes shaped glyphs from `HarfRust` and converts them into `ClusterData` and `Glyph`. /// /// # Parameters @@ -690,6 +820,9 @@ impl LayoutData { /// ## Input Parameters: /// * `direction` - Direction of the text. /// * `scale_factor` - Scaling factor used to convert font units to the target size. +/// * `vertical_upright` - Rito: whether the run was shaped top to bottom. +/// * `vertical_fallback` - Rito: the vertical advance a face without vertical metrics gives +/// in place of the shaper's default (see [`vertical_fallback`]). /// * `glyph_infos` - `HarfRust` glyph information in visual order. /// * `glyph_positions` - `HarfRust` glyph positioning data in visual order. /// * `char_infos` - Character information from text analysis, indexed by cluster ID. @@ -700,6 +833,8 @@ fn process_clusters>( clusters: &mut Vec, glyphs: &mut Vec, scale_factor: f32, + vertical_upright: bool, + vertical_fallback: Option<(i32, f32)>, glyph_infos: &[harfrust::GlyphInfo], glyph_positions: &[harfrust::GlyphPosition], char_infos: &[(CharInfo, u16)], @@ -826,13 +961,34 @@ fn process_clusters>( pending_inline_glyph = None; } - let glyph = Glyph { - id: glyph_info.glyph_id, - style_index: char_info.1, - x: (glyph_pos.x_offset as f32) * scale_factor, - // Convert from font space (Y-up) to layout space (Y-down) - y: -(glyph_pos.y_offset as f32) * scale_factor, - advance: (glyph_pos.x_advance as f32) * scale_factor, + // Rito: a run shaped top to bottom advances down the line by its + // vertical advance; the offsets from each glyph's vertical origin + // are the painter's, which hangs the glyph from that origin itself. + let glyph = if vertical_upright { + let units = -glyph_pos.y_advance; + Glyph { + id: glyph_info.glyph_id, + style_index: char_info.1, + x: 0.0, + y: 0.0, + // The positioning a glyph took moves it from the fallback + // as from the shaper's default; a mark zeroed stays zero. + advance: match vertical_fallback { + Some((default, fallback)) if units != 0 => { + fallback + ((units - default) as f32) * scale_factor + } + _ => (units as f32) * scale_factor, + }, + } + } else { + Glyph { + id: glyph_info.glyph_id, + style_index: char_info.1, + x: (glyph_pos.x_offset as f32) * scale_factor, + // Convert from font space (Y-up) to layout space (Y-down) + y: -(glyph_pos.y_offset as f32) * scale_factor, + advance: (glyph_pos.x_advance as f32) * scale_factor, + } }; cluster_advance += glyph.advance; // Push any pending glyph. If it was a zero-offset, single glyph cluster, it would @@ -1003,7 +1159,7 @@ fn push_cluster( glyph_len: final_glyph_len, text_len: cluster_start_char.1.len_utf8() as u8, glyph_offset: final_glyph_offset, - text_offset: cluster_start_char.0 as u16, + text_offset: cluster_start_char.0 as u32, advance: final_advance, }); } diff --git a/crates/vendor/parley/src/layout/layout.rs b/crates/vendor/parley/src/layout/layout.rs index 96f5e644d..b34a5f518 100644 --- a/crates/vendor/parley/src/layout/layout.rs +++ b/crates/vendor/parley/src/layout/layout.rs @@ -4,7 +4,10 @@ use crate::InlineBox; use crate::layout::alignment::align; use crate::layout::alignment::unjustify; -use crate::layout::data::{LayoutData, LineData, LineItemData}; +use crate::analysis::Boundary; +use crate::layout::data::{ClusterData, ClusterInfo, LayoutData, LineData, LineItemData}; +use alloc::sync::Arc; +use core::ops::Range; use alloc::vec::Vec; use crate::style::Brush; use core::cmp::Ordering; @@ -104,6 +107,87 @@ impl Layout { } /// Returns the number of lines in the layout. + /// Rito: the index, in layout order, of the cluster holding text index + /// `byte`, found through the runs rather than the lines: it answers + /// before the layout is broken into lines. + pub fn cluster_index_at(&self, byte: usize) -> Option { + let runs = &self.data.runs; + let run = runs + .get(runs.partition_point(|run| run.text_range.end <= byte)) + .filter(|run| run.text_range.contains(&byte))?; + let clusters = &self.data.clusters[run.cluster_range.clone()]; + let index = clusters.partition_point(|cluster| cluster.text_range(run).end <= byte); + clusters + .get(index) + .filter(|cluster| cluster.text_range(run).contains(&byte)) + .map(|_| run.cluster_range.start + index) + } + + /// Rito: the index, in layout order, of the cluster whose text starts at + /// `byte`, if one does. + pub fn cluster_starting_at(&self, byte: usize) -> Option { + let index = self.cluster_index_at(byte)?; + let run = &self.data.runs[self + .data + .runs + .partition_point(|run| run.cluster_range.end <= index)]; + (self.data.clusters[index].text_range(run).start == byte).then_some(index) + } + + /// Rito: sets the clusters starting in `range` as one unit `advance` + /// long that a line never breaks inside, as a vertical line sets a + /// `text-combine-upright` run: the first carries the advance and + /// starts a ligature, the rest continue it with none, so breaking + /// takes them as one cluster and only the first's boundary breaks. + /// Call it before the layout is first broken into lines. + pub fn combine_upright(&mut self, range: Range, advance: f32) { + let Some(first) = self.cluster_index_at(range.start) else { + return; + }; + let runs = Arc::make_mut(&mut self.data.runs); + let clusters = Arc::make_mut(&mut self.data.clusters); + let mut index = first; + for run in runs.iter_mut() { + if run.cluster_range.end <= index { + continue; + } + while index < run.cluster_range.end { + let cluster = &mut clusters[index]; + if cluster.text_range(run).start >= range.end { + break; + } + let flags = cluster.flags + & !(ClusterData::LIGATURE_START | ClusterData::LIGATURE_COMPONENT); + if index == first { + cluster.flags = flags | ClusterData::LIGATURE_START | ClusterData::COMBINED; + cluster.advance = advance; + } else { + cluster.flags = flags | ClusterData::LIGATURE_COMPONENT | ClusterData::COMBINED; + cluster.advance = 0.0; + cluster.info = ClusterInfo::new(Boundary::None, cluster.info.source_char()); + } + index += 1; + } + run.advance = clusters[run.cluster_range.clone()] + .iter() + .map(|cluster| cluster.advance) + .sum(); + if index < run.cluster_range.end { + break; + } + } + } + + /// Rito: the font of the run holding text index `byte`, found through + /// the runs: it answers before the layout is broken into lines. + pub fn font_at(&self, byte: usize) -> Option<&crate::FontData> { + let runs = &self.data.runs; + let run = runs + .get(runs.partition_point(|run| run.text_range.end <= byte)) + .filter(|run| run.text_range.contains(&byte))?; + self.data.fonts.get(run.font_index) + } + pub fn len(&self) -> usize { self.data.lines.len() } diff --git a/crates/vendor/parley/src/layout/line.rs b/crates/vendor/parley/src/layout/line.rs index 0628d8515..663708724 100644 --- a/crates/vendor/parley/src/layout/line.rs +++ b/crates/vendor/parley/src/layout/line.rs @@ -445,7 +445,8 @@ impl<'a, B: Brush> Iterator for GlyphRunIter<'a, B> { self.item_index += 1; self.cursor = GlyphCursor::default(); - if inline_box.kind == InlineBoxKind::InFlow { + // Rito: an inline box's start and end sizes take width. + if inline_box.kind == InlineBoxKind::InFlow || inline_box.kind.is_tag() { self.offset += inline_box.width; } return Some(PositionedLayoutItem::InlineBox(PositionedInlineBox { diff --git a/crates/vendor/parley/src/layout/line_break.rs b/crates/vendor/parley/src/layout/line_break.rs index 194f0b29e..747076d10 100644 --- a/crates/vendor/parley/src/layout/line_break.rs +++ b/crates/vendor/parley/src/layout/line_break.rs @@ -53,6 +53,11 @@ struct LineState { /// We lag the text-wrap-mode by one cluster due to line-breaking boundaries only /// being triggered on the cluster after the linebreak. text_wrap_mode: TextWrapMode, + /// Rito: where the line's content ends — its x past the last cluster, + /// box or tag that does not hang — and that last cluster, if it is + /// text. + content_x: f32, + content_cluster: Option, } #[derive(Clone, Default)] @@ -167,6 +172,9 @@ pub struct BreakerState { prev_boundary: Option, /// Saved breaker state for the last emergency line-breaking opportunity emergency_boundary: Option, + /// Rito: the breaker state before the open tags met since the last + /// cluster, where a break the next cluster allows is taken. + open_tag_boundary: Option, } impl Default for BreakerState { @@ -185,6 +193,7 @@ impl Default for BreakerState { line: LineState::default(), prev_boundary: None, emergency_boundary: None, + open_tag_boundary: None, } } } @@ -204,6 +213,7 @@ impl BreakerState { self.item_idx += 1; self.line.items.end += 1; self.line.x = next_x; + self.line.content_x = next_x; self.add_line_height(box_height); } @@ -235,6 +245,17 @@ impl BreakerState { self.run_idx = prev_state.run_idx; self.cluster_idx = prev_state.cluster_idx; self.line = prev_state.state; + self.open_tag_boundary = None; + } + + /// Rito: the iteration state here, to break at later. + fn boundary_here(&self) -> PrevBoundaryState { + PrevBoundaryState { + item_idx: self.item_idx, + run_idx: self.run_idx, + cluster_idx: self.cluster_idx, + state: self.line.clone(), + } } #[inline(always)] @@ -325,6 +346,22 @@ fn record_fit(fit: &mut (f32, f32), next_x: f32, max_advance: f32) -> bool { fits } +/// Rito: whether a line ending where `line`'s content ends fits once its +/// last glyph is reshaped without the text after it (Blink's +/// `reshape_line_end`); the comparison joins the recorded fits. +fn line_end_fits( + data: &LayoutData, + fit: &mut (f32, f32), + line: &LineState, + max_advance: f32, +) -> bool { + let Some(cluster) = line.content_cluster else { + return true; + }; + let delta = data.line_end_delta(cluster); + delta <= 0.0 || record_fit(fit, line.content_x + delta, max_advance) +} + impl<'a, B: Brush> BreakLines<'a, B> { pub(crate) fn new(layout: &'a mut Layout) -> Self { layout.data.width = 0.; @@ -371,6 +408,7 @@ impl<'a, B: Brush> BreakLines<'a, B> { self.state.line.running_line_height = 0.; self.state.prev_boundary = None; self.state.emergency_boundary = None; + self.state.open_tag_boundary = None; self.finish_line(self.lines.lines.len() - 1, line_height); @@ -430,6 +468,29 @@ impl<'a, B: Brush> BreakLines<'a, B> { } } + /// Rito: runs `inspect` on the layout with the lines committed so far + /// in place, as `Layout::get` and every cluster lookup read them. The + /// layout's extents stay unset until the breaker is done. + pub fn inspect(&mut self, inspect: impl FnOnce(&Layout) -> R) -> R { + self.lines.swap(&mut self.layout.data); + let result = inspect(self.layout); + self.lines.swap(&mut self.layout.data); + result + } + + /// Rito: the advance of cluster `index`, in layout order. + pub fn cluster_advance(&self, index: usize) -> f32 { + self.layout.data.clusters[index].advance + } + + /// Rito: sets the advance of cluster `index`, in layout order, so the + /// lines from here on break as if the paragraph were built with other + /// spacing. Its glyphs keep their advances: such a layout is broken to + /// decide where its lines end, never drawn. + pub fn set_cluster_advance(&mut self, index: usize, advance: f32) { + crate::layout::data::owned(&mut self.layout.data.clusters)[index].advance = advance; + } + /// Reverts the to an externally saved state. pub fn revert_to(&mut self, state: BreakerState) { self.state = state; @@ -516,9 +577,58 @@ impl<'a, B: Brush> BreakLines<'a, B> { LayoutItemKind::InlineBox => { let inline_box = &self.layout.data.inline_boxes[item.index]; + // Rito: an inline box's start and end sizes (Blink's open + // and close tag items) take their width and follow the + // content they edge. + if inline_box.kind == InlineBoxKind::OpenTag { + if self.state.open_tag_boundary.is_none() { + self.state.open_tag_boundary = Some(self.state.boundary_here()); + } + let next_x = self.state.line.x + inline_box.width; + self.state.append_inline_box_to_line(next_x, 0.0); + continue; + } + if inline_box.kind == InlineBoxKind::CloseTag { + let next_x = self.state.line.x + inline_box.width; + if record_fit(&mut self.fit, next_x, max_advance) + || self.state.line.text_wrap_mode != TextWrapMode::Wrap + { + self.state.append_inline_box_to_line(next_x, 0.0); + continue; + } + // It cannot leave the content it closes: the line + // breaks at the last opportunity before that content. + if let Some(prev) = self.state.prev_boundary.take() { + self.state.reset_to(prev); + self.fit.0 = self.state.line.x; + return self.start_new_line( + BreakReason::Regular, + max_advance, + line_indent, + ); + } + if let Some(prev) = self.state.emergency_boundary.take() { + self.state.reset_to(prev); + self.fit.0 = self.state.line.x; + return self.start_new_line( + BreakReason::Emergency, + max_advance, + line_indent, + ); + } + self.state.append_inline_box_to_line(next_x, 0.0); + continue; + } + // Rito: open tags right before this box open the next + // line with it when the box does not fit. + let open_tag = self.state.open_tag_boundary.take(); + let (width_contribution, height_contribution) = match inline_box.kind { InlineBoxKind::InFlow => (inline_box.width, inline_box.height), - InlineBoxKind::OutOfFlow => (0.0, 0.0), + // Rito: handled above. + InlineBoxKind::OutOfFlow + | InlineBoxKind::OpenTag + | InlineBoxKind::CloseTag => (0.0, 0.0), // If the box is a `CustomOutOfFlow` box then we yield control flow back to the caller. // It is then the caller's responsibility to handle placement of the box. InlineBoxKind::CustomOutOfFlow => { @@ -548,10 +658,20 @@ impl<'a, B: Brush> BreakLines<'a, B> { self.state .append_inline_box_to_line(next_x, height_contribution); + self.state.line.content_cluster = None; // We can always line break after an inline box self.state.mark_line_break_opportunity(); } else { + if let Some(before) = open_tag.filter(|before| before.state.x != 0.0) { + self.state.reset_to(before); + self.fit.0 = self.state.line.x; + return self.start_new_line( + BreakReason::Regular, + max_advance, + line_indent, + ); + } // If we're at the start of the line, this box will never fit, so consume it and accept the overflow. let reason = if self.state.line.x == 0.0 { // println!("BOX EMERGENCY BREAK"); @@ -585,6 +705,19 @@ impl<'a, B: Brush> BreakLines<'a, B> { let is_newline = whitespace == Whitespace::Newline; let is_space = whitespace.is_space_or_nbsp(); let boundary = cluster.info().boundary(); + // Rito: a space or other space separator overflowing + // the line hangs past its end when the line may not + // break before it but may after its run (Blink's + // `ShapingLineBreaker` takes the first opportunity + // at or after an overflowing breakable space and + // hangs the spaces before it). The no-break space + // never hangs. + let hangs = is_hangable_space(cluster.info().source_char()) + && boundary != Boundary::Line + && breaks_after_space_run( + &self.layout.data.clusters, + self.state.cluster_idx, + ); let line_height = run.metrics().line_height; let max_height_exceeded = self.state.line.max_height_exceeded; let style = &self.layout.data.styles[cluster.data.style_index as usize]; @@ -593,16 +726,17 @@ impl<'a, B: Brush> BreakLines<'a, B> { let text_wrap_mode = self.state.line.text_wrap_mode; self.state.line.text_wrap_mode = style.text_wrap_mode; - if boundary == Boundary::Line && text_wrap_mode == TextWrapMode::Wrap { - // We do not currently handle breaking within a ligature, so we ignore boundaries in such a position. - // - // We also don't record boundaries when the advance is 0. As we do not want overflowing content to cause extra consecutive - // line breaks. We should accept the overflowing fragment in that scenario. - if !is_ligature_continuation && self.state.line.x != 0.0 { - self.state.mark_line_break_opportunity(); - // break_opportunity = true; - } - } else if is_newline { + // Rito: open tags met since the last cluster. A break + // this cluster allows is taken before them (unless + // they open the line); a forced break keeps them. + let open_tag = self.state.open_tag_boundary.take(); + + // Rito: a forced break is taken before any soft wrap + // opportunity, as upstream now does. A line break + // override can mark the newline itself breakable + // (`line-break: anywhere`, or Chromium's tailoring + // after a space), and the soft branch lost the break. + if is_newline { if max_height_exceeded { return self.max_height_break_data(line_height); } @@ -613,6 +747,44 @@ impl<'a, B: Brush> BreakLines<'a, B> { max_advance, line_indent, ); + } else if boundary == Boundary::Line && text_wrap_mode == TextWrapMode::Wrap + { + // We do not currently handle breaking within a ligature, so we ignore boundaries in such a position. + // + // We also don't record boundaries when the advance is 0. As we do not want overflowing content to cause extra consecutive + // line breaks. We should accept the overflowing fragment in that scenario. + if !is_ligature_continuation { + match open_tag { + // Rito: an opportunity whose line end, + // reshaped alone, overflows is passed + // over (Blink's `reshape_line_end` moves + // to the opportunity before it). + Some(before) => { + if before.state.x != 0.0 + && line_end_fits( + &self.layout.data, + &mut self.fit, + &before.state, + max_advance, + ) + { + self.state.prev_boundary = Some(before); + } + } + None => { + if self.state.line.x != 0.0 + && line_end_fits( + &self.layout.data, + &mut self.fit, + &self.state.line, + max_advance, + ) + { + self.state.mark_line_break_opportunity(); + } + } + } + } } else if // This text can contribute "emergency" line breaks. style.overflow_wrap != OverflowWrap::Normal && !is_ligature_continuation @@ -620,14 +792,26 @@ impl<'a, B: Brush> BreakLines<'a, B> { // If we're at the start of the line, this particular cluster will never fit, so it's not a valid emergency break opportunity. && self.state.line.x != 0.0 { - self.state.mark_emergency_break_opportunity(); + match open_tag { + Some(before) => { + if before.state.x != 0.0 { + self.state.emergency_boundary = Some(before); + } + } + None => self.state.mark_emergency_break_opportunity(), + } } // If current cluster is the start of a ligature, then advance state to include // the remaining clusters that make up the ligature let mut advance = cluster.advance(); if cluster.is_ligature_start() { - while let Some(cluster) = run.get(self.state.cluster_idx + 1) { + // Rito: `Run::get` takes an index within the run, + // as above; the absolute index read another run's + // clusters for every run but the paragraph's first. + while let Some(cluster) = + run.get(self.state.cluster_idx - cluster_start + 1) + { if !cluster.is_ligature_continuation() { break; } else { @@ -653,6 +837,10 @@ impl<'a, B: Brush> BreakLines<'a, B> { if is_space { self.state.line.num_spaces += 1; } + if !is_hangable_space(cluster.info().source_char()) { + self.state.line.content_x = next_x; + self.state.line.content_cluster = Some(self.state.cluster_idx - 1); + } } // Else we attempt to line break: // @@ -663,7 +851,16 @@ impl<'a, B: Brush> BreakLines<'a, B> { // Case: cluster is a space character (and wrapping is enabled) // // We hang any overflowing whitespace and then line-break. - if is_space && text_wrap_mode == TextWrapMode::Wrap { + // Rito: unless the line's last glyph, reshaped + // without the space, no longer fits and an + // earlier opportunity can take the line. + let reshaped_fits = line_end_fits( + &self.layout.data, + &mut self.fit, + &self.state.line, + max_advance, + ) || self.state.prev_boundary.is_none(); + if hangs && text_wrap_mode == TextWrapMode::Wrap && reshaped_fits { if max_height_exceeded { return self.max_height_break_data(line_height); } @@ -761,6 +958,13 @@ impl<'a, B: Brush> BreakLines<'a, B> { LayoutItemKind::InlineBox => { let inline_box = &self.layout.data.inline_boxes[item.index]; + if inline_box.kind.is_tag() { + // Rito: an inline box's start or end size takes its + // width and counts as no character. + let next_x = self.state.line.x + inline_box.width; + self.state.append_inline_box_to_line(next_x, 0.0); + continue; + } if inline_box.kind != InlineBoxKind::InFlow { self.state.append_inline_box_to_line(self.state.line.x, 0.0); continue; @@ -871,6 +1075,96 @@ impl<'a, B: Brush> BreakLines<'a, B> { Some(()) } + /// Rito: breaks the next line so that it holds exactly the content + /// before text index `end`: every cluster starting before it and every + /// in-flow box placed at or before it (a box sits before the text at its + /// index). The line is forced, so it records no fit, and it can be + /// reverted like a line `break_next` computed. Returns `None` once all + /// content is placed. + pub fn break_next_to(&mut self, end: usize) -> Option<()> { + if self.done { + return None; + } + self.prev_state = Some(self.state.clone()); + self.fit = (f32::NEG_INFINITY, f32::INFINITY); + let line_indent = self.resolve_indent(); + let item_count = self.layout.data.items.len(); + while self.state.item_idx < item_count { + let item = &self.layout.data.items[self.state.item_idx]; + match item.kind { + LayoutItemKind::InlineBox => { + let inline_box = &self.layout.data.inline_boxes[item.index]; + // Rito: an open tag at the line's end opens the next line + // with what it opens; a close tag there closes content + // the line holds. + let past_end = match inline_box.kind { + InlineBoxKind::OpenTag => inline_box.index >= end, + _ => inline_box.index > end, + }; + if inline_box.kind.is_tag() { + if past_end { + self.start_new_line(BreakReason::Regular, f32::MAX, line_indent); + return Some(()); + } + let next_x = self.state.line.x + inline_box.width; + self.state.append_inline_box_to_line(next_x, 0.0); + continue; + } + if inline_box.kind != InlineBoxKind::InFlow { + self.state.append_inline_box_to_line(self.state.line.x, 0.0); + continue; + } + if past_end { + self.start_new_line(BreakReason::Regular, f32::MAX, line_indent); + return Some(()); + } + let next_x = self.state.line.x + inline_box.width; + self.state + .append_inline_box_to_line(next_x, inline_box.height); + } + LayoutItemKind::TextRun => { + let run_data = &self.layout.data.runs[item.index]; + let run = Run::new(self.layout, 0, 0, run_data, None); + let cluster_start = run_data.cluster_range.start; + let cluster_end = run_data.cluster_range.end; + while self.state.cluster_idx < cluster_end { + let cluster = run.get(self.state.cluster_idx - cluster_start).unwrap(); + if cluster.text_range().start >= end { + self.start_new_line(BreakReason::Regular, f32::MAX, line_indent); + return Some(()); + } + let whitespace = cluster.info().whitespace(); + let is_newline = whitespace == Whitespace::Newline; + // Newlines don't contribute to line width (as in + // `break_next`). + let next_x = if is_newline { + self.state.line.x + } else { + self.state.line.x + cluster.advance() + }; + let line_height = run.metrics().line_height; + self.state.append_cluster_to_line(next_x, line_height); + if whitespace.is_space_or_nbsp() { + self.state.line.num_spaces += 1; + } + if is_newline { + self.start_new_line(BreakReason::Explicit, f32::MAX, line_indent); + return Some(()); + } + } + self.state.run_idx += 1; + self.state.item_idx += 1; + } + } + } + if self.state.line.items.end == 0 { + self.state.line.items.end = 1; + } + self.done = true; + self.start_new_line(BreakReason::None, f32::MAX, line_indent); + Some(()) + } + /// Breaks all remaining lines with the specified maximum advance. This /// consumes the line breaker. pub fn break_remaining(mut self, max_advance: f32) { @@ -1375,3 +1669,37 @@ fn reorder_line_items(runs: &mut [LineItemData]) { } } } + +/// Rito: whether a line may break after the run of hangable spaces starting +/// at cluster `index`: at the end of the text, before a newline, or where the +/// next cluster allows a break before it. +fn breaks_after_space_run(clusters: &[ClusterData], index: usize) -> bool { + clusters + .iter() + .skip(index + 1) + .find(|cluster| !is_hangable_space(cluster.info.source_char())) + .is_none_or(|cluster| { + cluster.info.whitespace() == Whitespace::Newline + || matches!( + cluster.info.boundary(), + Boundary::Line | Boundary::Mandatory + ) + }) +} + +/// Rito: a breakable space (Blink's `IsBreakableSpace`: the space and the +/// tab) or another space separator. +fn is_hangable_space(character: char) -> bool { + matches!(character, ' ' | '\t') || is_other_space_separator(character) +} + +/// Rito: a space separator other than the space and the no-break space +/// (CSS Text's "other space separators", Blink's +/// `unicode::IsOtherSpaceSeparator`): the ideographic space, the +/// typographic spaces and their kin. +fn is_other_space_separator(character: char) -> bool { + matches!( + character, + '\u{1680}' | '\u{2000}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' + ) +} diff --git a/crates/vendor/parley/src/layout/run.rs b/crates/vendor/parley/src/layout/run.rs index 944b4cb10..8ad615cb4 100644 --- a/crates/vendor/parley/src/layout/run.rs +++ b/crates/vendor/parley/src/layout/run.rs @@ -96,6 +96,12 @@ impl<'a, B: Brush> Run<'a, B> { self.data.bidi_level & 1 != 0 } + /// Rito: whether the run was shaped top to bottom, upright in a + /// vertical line. + pub fn is_vertical_upright(&self) -> bool { + self.data.vertical_upright + } + /// Returns the cluster range for the run. pub fn cluster_range(&self) -> Range { self.line_data diff --git a/crates/vendor/parley/src/resolve/mod.rs b/crates/vendor/parley/src/resolve/mod.rs index f9cfc01d2..c77368061 100644 --- a/crates/vendor/parley/src/resolve/mod.rs +++ b/crates/vendor/parley/src/resolve/mod.rs @@ -166,6 +166,7 @@ impl ResolveContext { StyleProperty::WordBreak(value) => WordBreak(*value), StyleProperty::OverflowWrap(value) => OverflowWrap(*value), StyleProperty::TextWrapMode(value) => TextWrapMode(*value), + StyleProperty::VerticalUpright(value) => VerticalUpright(*value), } } @@ -203,6 +204,7 @@ impl ResolveContext { word_break: raw_style.word_break, overflow_wrap: raw_style.overflow_wrap, text_wrap_mode: raw_style.text_wrap_mode, + vertical_upright: raw_style.vertical_upright, } } @@ -387,6 +389,8 @@ pub(crate) enum ResolvedProperty { OverflowWrap(OverflowWrap), /// Control over non-"emergency" line-breaking. TextWrapMode(TextWrapMode), + /// Rito: upright in a vertical line. + VerticalUpright(bool), } /// Flattened group of style properties. @@ -426,6 +430,8 @@ pub(crate) struct ResolvedStyle { pub(crate) overflow_wrap: OverflowWrap, /// Control over non-"emergency" line-breaking. pub(crate) text_wrap_mode: TextWrapMode, + /// Rito: upright in a vertical line, shaped top to bottom. + pub(crate) vertical_upright: bool, } impl ResolvedStyle { @@ -456,6 +462,7 @@ impl ResolvedStyle { WordBreak(value) => self.word_break = value, OverflowWrap(value) => self.overflow_wrap = value, TextWrapMode(value) => self.text_wrap_mode = value, + VerticalUpright(value) => self.vertical_upright = value, } } @@ -485,6 +492,7 @@ impl ResolvedStyle { WordBreak(value) => self.word_break == *value, OverflowWrap(value) => self.overflow_wrap == *value, TextWrapMode(value) => self.text_wrap_mode == *value, + VerticalUpright(value) => self.vertical_upright == *value, } } diff --git a/crates/vendor/parley/src/shape/mod.rs b/crates/vendor/parley/src/shape/mod.rs index f2c1b339d..53932154e 100644 --- a/crates/vendor/parley/src/shape/mod.rs +++ b/crates/vendor/parley/src/shape/mod.rs @@ -59,6 +59,8 @@ struct Item { features: Resolved, word_spacing: f32, letter_spacing: f32, + /// Rito: shaped top to bottom, upright in a vertical line. + vertical_upright: bool, } #[allow(clippy::too_many_arguments)] @@ -106,6 +108,7 @@ pub(crate) fn shape_text<'a, B: Brush>( features: style.font_features, word_spacing: style.word_spacing, letter_spacing: style.letter_spacing, + vertical_upright: style.vertical_upright, }; let mut char_range = 0..0; @@ -133,6 +136,7 @@ pub(crate) fn shape_text<'a, B: Brush>( || style.font_features != item.features || !nearly_eq(style.letter_spacing, item.letter_spacing) || !nearly_eq(style.word_spacing, item.word_spacing) + || style.vertical_upright != item.vertical_upright { break_run = true; } @@ -185,6 +189,7 @@ pub(crate) fn shape_text<'a, B: Brush>( item.features = style.font_features; item.word_spacing = style.word_spacing; item.letter_spacing = style.letter_spacing; + item.vertical_upright = style.vertical_upright; text_range.start = text_range.end; char_range.start = char_range.end; } @@ -393,7 +398,10 @@ fn shape_item<'a, B: Brush>( }, ); - let direction = if item.level & 1 != 0 { + // Rito: a vertical line's upright run shapes top to bottom. + let direction = if item.vertical_upright { + harfrust::Direction::TopToBottom + } else if item.level & 1 != 0 { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight @@ -458,7 +466,7 @@ fn shape_item<'a, B: Brush>( buffer.set_script(hb_script); - if let Some(lang) = language { + if let Some(lang) = language.clone() { buffer.set_language(lang); } @@ -488,17 +496,144 @@ fn shape_item<'a, B: Brush>( item.style_index, item.word_spacing, item.letter_spacing, + item.vertical_upright, segment_text, segment_infos, (text_range.start + segment_start_offset)..(text_range.start + segment_end_offset), harf_shaper.coords(), ); + // Rito: the possible line ends the shaper marked unsafe to break, + // shaped alone, for the line breaker's reshape of a line's end. + if direction == harfrust::Direction::LeftToRight { + if let Some(run) = layout.data.runs.last() { + let first_cluster = run.cluster_range.start; + let units_per_em = skrifa::FontRef::from_index(font.font.blob.as_ref(), font.font.index) + .ok() + .and_then(|font| { + use skrifa::raw::TableProvider as _; + font.head().ok().map(|head| head.units_per_em()) + }) + .unwrap_or(0); + if units_per_em > 0 { + let mut deltas = Vec::new(); + line_end_deltas( + &LineEndShaping { + shaper: &harf_shaper, + plan: shaper_plan, + features: &scx.features, + point_size: item.size, + script: hb_script, + language: language.as_ref(), + scale: item.size / f32::from(units_per_em), + }, + segment_text, + segment_infos, + &glyph_buffer, + &mut deltas, + ); + let clusters = &layout.data.clusters; + let deltas = deltas.into_iter().filter_map(|(char_index, delta)| { + let byte = segment_text.char_indices().nth(char_index)?.0; + let cluster = first_cluster + char_index; + // A cluster's offset counts from its run's text. + (clusters.get(cluster)?.text_offset as usize == byte) + .then_some((cluster as u32, delta)) + }); + crate::layout::data::owned(&mut layout.data.line_end_deltas).extend(deltas); + } + } + } + // Replace buffer to reuse allocation in next iteration. scx.unicode_buffer = Some(glyph_buffer.clear()); } } +/// Rito: what reshaping a line's end alone needs of the run's shaping. +struct LineEndShaping<'a> { + shaper: &'a harfrust::Shaper<'a>, + plan: &'a harfrust::ShapePlan, + features: &'a [harfrust::Feature], + point_size: f32, + script: harfrust::Script, + language: Option<&'a harfrust::Language>, + /// Font units to layout units. + scale: f32, +} + +/// Rito: for each character of a left-to-right segment that ends a +/// cluster where the shaper marked breaking unsafe and a line may end — +/// before a space or another space separator, or before a break +/// opportunity — how much wider that cluster shapes alone than in +/// context, by character index (Blink reshapes such a line end, so the +/// line's last glyph loses its kerning with the space hanging after it). +fn line_end_deltas( + shaping: &LineEndShaping<'_>, + segment_text: &str, + segment_infos: &[(CharInfo, u16)], + glyph_buffer: &harfrust::GlyphBuffer, + out: &mut Vec<(usize, f32)>, +) { + let chars: Vec = segment_text.chars().collect(); + let infos = glyph_buffer.glyph_infos(); + let positions = glyph_buffer.glyph_positions(); + let hangs = |character: char| { + matches!( + character, + ' ' | '\t' | '\u{1680}' | '\u{2000}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' + ) + }; + for (index, info) in infos.iter().enumerate().skip(1) { + let start = info.cluster as usize; + let previous = infos[index - 1].cluster as usize; + if previous >= start || !info.unsafe_to_break() { + continue; + } + let (Some(&next), Some(&last)) = (chars.get(start), chars.get(start - 1)) else { + continue; + }; + let line_may_end = hangs(next) + || segment_infos + .get(start) + .is_some_and(|(char_info, _)| char_info.boundary == crate::analysis::Boundary::Line); + if !line_may_end || hangs(last) { + continue; + } + let in_context: i32 = infos + .iter() + .zip(positions) + .filter(|(glyph, _)| glyph.cluster as usize == previous) + .map(|(_, position)| position.x_advance) + .sum(); + let mut buffer = harfrust::UnicodeBuffer::new(); + for (offset, character) in chars[previous..start].iter().enumerate() { + buffer.add(*character, offset as u32); + } + buffer.set_direction(harfrust::Direction::LeftToRight); + buffer.set_script(shaping.script); + if let Some(language) = shaping.language { + buffer.set_language(language.clone()); + } + let alone: i32 = shaping + .shaper + .shape( + buffer, + ShapeOptions::new() + .plan(Some(shaping.plan)) + .features(shaping.features) + .point_size(Some(shaping.point_size)), + ) + .glyph_positions() + .iter() + .map(|position| position.x_advance) + .sum(); + if alone != in_context { + out.push((previous, (alone - in_context) as f32 * shaping.scale)); + } + } +} + fn real_script(script: Script) -> bool { script != Script::Common && script != Script::Unknown && script != Script::Inherited } diff --git a/crates/vendor/parley/src/style/mod.rs b/crates/vendor/parley/src/style/mod.rs index a8f720e44..69cf2cbcf 100644 --- a/crates/vendor/parley/src/style/mod.rs +++ b/crates/vendor/parley/src/style/mod.rs @@ -117,6 +117,10 @@ pub enum StyleProperty<'a, B: Brush> { OverflowWrap(OverflowWrap), /// Control over non-"emergency" line-breaking. TextWrapMode(TextWrapMode), + /// Rito: whether the text stands upright in a vertical line, shaped top + /// to bottom (vertical advances and glyph forms, no horizontal + /// kerning), as Blink shapes a vertical line's upright runs. + VerticalUpright(bool), } /// Unresolved styles. @@ -168,6 +172,8 @@ pub struct TextStyle<'family, 'settings, B: Brush> { pub overflow_wrap: OverflowWrap, /// Control over non-"emergency" line-breaking. pub text_wrap_mode: TextWrapMode, + /// Rito: whether the text stands upright in a vertical line. + pub vertical_upright: bool, } impl Default for TextStyle<'static, 'static, B> { @@ -196,6 +202,7 @@ impl Default for TextStyle<'static, 'static, B> { word_break: WordBreak::default(), overflow_wrap: OverflowWrap::default(), text_wrap_mode: TextWrapMode::default(), + vertical_upright: false, } } } diff --git a/docs/api/reader.md b/docs/api/reader.md index a80fe14cd..7b8c5ead3 100644 --- a/docs/api/reader.md +++ b/docs/api/reader.md @@ -32,7 +32,7 @@ other native hosts bridge the C ABI (see | `width` | `number` | required | Viewport width in logical pixels | | `height` | `number` | required | Viewport height in logical pixels | | `margin` | `ReaderMargin` | `40` | Page margins; see below | -| `spread` | `'single' \| 'double'` | `'single'` | Requested spread mode | +| `spread` | `ReaderSpreadMode` | `'single'` | Requested spread mode; see below | | `spreadGap` | `number` | `20` | Least gap between pages in double mode | | `backgroundColor` | `string \| null` | `'#ffffff'` | Page background; `null` restores white | | `foregroundColor` | `string \| null` | unset | Reader-wide override; `null` clears it | @@ -219,6 +219,7 @@ faces do not cover. Variable fonts are rejected. | `totalSpreads` | Number of spreads in the committed layout | | `pageCount` | Number of pages in the committed layout | | `dpr` | Device pixel ratio the committed layout is painted at | +| `getScrollLayout()` | A scrolled layout's windows end to end; `undefined` for pages (see below) | | `getImageBlobUrl(src)` | Create or asynchronously resolve an EPUB image URL | | `interactions` | Optional revision-safe semantic interaction provider | @@ -238,6 +239,23 @@ Changes made while a layout is under way wait for it: it commits, and then only the newest of them is laid out. A host that sends a new size every frame sees a new size each time a layout ends. +`spread: 'scrolled'` lays each chapter out as one continuous strip read +through windows the size of the viewport, every window a spread of one page. +The windows run down the screen, or leftwards for a book whose spine's +`page-progression-direction` is `rtl`, whose vertical-rl chapters run +leftwards as strips; a chapter whose text runs across the book's axis is +paginated, each page one window. `getScrollLayout()` gives `axis` +(`'down'` or `'left'`), `windowLength` (the viewport's height, or its width) +and per chapter its `firstSpread`, `spreadCount` and `extent` along the axis. +Within a chapter window `k` starts `floor(k × windowLength × dpr + 0.5)` +device pixels in and the chapter ends at `floor(extent × dpr + 0.5)`; the +chapters follow one another end to end, so a host placing each window there +tiles the book without a seam. `renderSpreadTo` paints a window into a target +of the spread's size or of the window's own, and a locator resolved by +`navigateToLocator` carries `blockOffset`, where its line starts +in its window. `@ritojs/kit`'s `createScrollController` is a view built on +these. + A spread carries page indexes only; page geometry comes from `getLayoutGeometry()` and page content from the rendered frame and `interactions`. In double mode the two pages sit on the device grid, as a @@ -271,11 +289,20 @@ next-block boundary. When the following flow belongs to the same chapter, against one currently committed layout, so callers never combine geometry from two versions. A caret belongs to the layout that answered it: after either commit, a caret from before resolves `undefined`. +A caret's `geometry` is the segment across its line, as a rect with no extent +along the line: `width` is 0 on a horizontal line and `height` 0 down a +vertical one. Its `writingMode` (`ReaderWritingMode`, `'horizontal-tb'` or +`'vertical-rl'`) is the mode of that line. On a `vertical-rl` page every target, +outline node, range rectangle and caret is where its column is drawn, in the +same page-content space as a horizontal page's. `resolveTextSelectionMovement()`, when supported, atomically rebinds a fixed anchor and live focus, advances the focus by a typed character, word, visual-line, line-edge, paragraph, or chapter-edge movement, and returns the exact new range. Vertical line moves return a `preferredInlinePosition` that callers pass into the -next vertical move to preserve sticky x. Reaching an incomplete retained tail is +next vertical move to preserve sticky x. Movements are named for a horizontal +line; on a `vertical-rl` focus a host reads an arrow key as the key a quarter turn +from it, as the browser does (down moves along the line, left to the next line), +which the kit's keyboard selection does. Reaching an incomplete retained tail is reported as typed `pending`. `resolveTextRangeFromSource()`, when supported, resolves a durable `{ href, sourceRange }` as a range in the committed layout, carets included: diff --git a/docs/development/verification-instruments.md b/docs/development/verification-instruments.md index 79e4b5913..32a2b5ba2 100644 --- a/docs/development/verification-instruments.md +++ b/docs/development/verification-instruments.md @@ -31,6 +31,10 @@ Rules that experience has made binding: - Prune capture trees after harvesting; they are hundreds of megabytes. - Refactors are verified by comparing the engine captures byte for byte against a baseline walk of the same book. +- Every instrument launches Chromium through `tools/oracle-chromium.mjs`: + the full build with no smart minimum font size, in English. On macOS + the test browser follows the system language unless it has its own: + `defaults write com.google.chrome.for.testing AppleLanguages -array en-US`. `prune-walks.sh` drops the capture trees (`engine/`, `truth/`, `gallery/`, `book/`) of every `walk-*/` directory in `tools/corpus-oracle` except the @@ -156,6 +160,28 @@ JSON shape: a hand-written corpus, except `text-vertical.json`, which the ignored `write_vertical_paint_parity_fixture` test writes from the painter's own output. +## Scroll seams (the pens across scrolled windows) + +```bash +node tools/paint-parity/seams.mjs [outRoot] +``` + +Lowers neighbouring scrolled windows of synthetic chapters (one of them +vertical-rl, whose windows run leftwards) and a real chapter at ratios 1, +1.25, 1.5, 2, 2.625 and 3, one by one and as one run +(`cargo test -p rito-core --lib lower_scroll_seam_fixtures`, env-gated), +rasters them through every pen lane, and `seam-diff.mjs` stacks each +window on the next (beside it, the next on the left, for leftward +windows, as each run's metadata `axis` says) and diffs the stack against +the run. That a window draws what the run draws is the engine tests +`a_window_draws_its_content_as_the_pair_with_the_window_above_draws_it` +and `a_leftward_window_draws_its_content_as_the_pair_with_the_window_right_of_it`; +the report lists how the pens raster the two apart: a canvas picks its +antialiasing mode from everything a frame holds, and a scaled image cut +by a window edge samples only what is visible, so a few pixels of a pair +can differ, now and then past the walk's raster floor. It fails only when +a stack is not the run's size or a render is missing. + ## GPU pen against the canvas pen on real books ```bash diff --git a/docs/integrations/ffi.md b/docs/integrations/ffi.md index eadceaec1..bf7256a0f 100644 --- a/docs/integrations/ffi.md +++ b/docs/integrations/ffi.md @@ -192,6 +192,76 @@ holds bytes the live encoder wrote for one of every primitive, and a Rust test keeps it in step. Format 1, the semantic display list, is no longer written; a decoder rejects it by its version field. +Wire changes landed with vertical forms (reader protocol version 9): + +- Artifacts and publications stamp protocol version 9; a decoder pinned + to 8 must move with it. +- Each font record of an artifact carries, after its byte length, a + count of OpenType feature settings and, for each, its tag (a string) + and value (`u32`). A record with features is a variant of a face: the + same bytes under a family of its own, painted with those features on + (`vert`, the vertical forms a vertical line's upright glyphs take). A + host registers it like any face, with the features as the face's + `font-feature-settings` descriptor, or turns them on for every run it + paints in that family. + +Wire changes landed with the scrolled reading mode (reader protocol +version 8): + +- Artifacts and publications stamp protocol version 8; a decoder pinned + to 7 must move with it. +- The layout record's `spread mode` has a third tag, 2 scrolled: each + chapter is one continuous strip read through windows the size of the + viewport, every window its own spread. A book whose spine's + `page-progression-direction` is `rtl` scrolls leftwards, others down; a + chapter whose blocks run across the book's axis (horizontal text in a + leftward book, vertical in a downward one) is paginated instead, each + page one window. Window `k` of a strip covers the chapter's CSS block + range `[k·L, (k+1)·L)` from its block start (`L` the viewport's height, + or its width for a leftward strip; the last window ends at the + chapter's extent) and the device pixels `[D(k), D(k+1))` along the axis, + `D(k) = floor(k·L·ratio + 0.5)`, the last ending at + `floor(extent·ratio + 0.5)`; a leftward strip starts at the right edge + of its first window. `first page alone` and `spread gap` are ignored. +- The artifact record carries an optional `f64` target block offset + after `book page count`: where the line its locator lands on starts in + its scrolled window, in CSS pixels from the window's block start (its + top, or its right edge for a leftward strip). Absent for a spread of + pages, for a page shown as a window, and for a window whose locator + names no line. +- The revision layout (`RITONVR1`, layout result) carries an optional + `f64` extent after each chapter's `end page` (the chapter's margins and + strip, scrolled revisions only) and, after `complete`, an optional `u8` + scroll axis: 0 down, 1 left. +- A located page (`RITONVR1`, location result tag 0) carries an optional + `f64` block offset after its locator match: in a scrolled revision, + where the line the locator lands on starts in that page's window, as an + artifact's target block offset. +- A scrolled session keeps up to 10 artifacts live (6 for pages). +- A caret (`RITOTIR1`) carries its geometry as `x, y, width, height` + (`width` is new, before `height`): the segment across its line, as a + rect with no extent along the line, so `width` is 0 on a horizontal + line and `height` 0 down a vertical one. A `u8` writing mode follows + the geometry option: 0 horizontal-tb, 1 vertical-rl, the mode of the + caret's line. A host reads arrow keys on a vertical-rl line as the + browser does, as the key a quarter turn from them on a horizontal line + (down along the line, left to the next line), before mapping them to + movements. +- Every rect and point of a vertical-rl page is in the page's drawn + space, like a horizontal page's: its hits, outline, caret geometry, + selection and range rects are columns where its lines are drawn, and a + point is read back the same way. A scrolled window's records move with + its pixels, by the device rows `D(k)` it is drawn from. + +Wire changes landed when the display list lost its digest (reader +protocol version 7): + +- Artifacts and publications stamp protocol version 7; a decoder pinned + to 6 must move with it. +- The artifact's display-list record no longer carries the 32-byte + SHA-256 digest after the command count: the display list bytes follow + it directly. + Wire changes landed when hit entries became the engine's classified targets (reader protocol version 6): diff --git a/docs/integrations/kit.md b/docs/integrations/kit.md index 60d14d1b4..496ad423a 100644 --- a/docs/integrations/kit.md +++ b/docs/integrations/kit.md @@ -26,6 +26,10 @@ Core exports: - `InteractionMode` - `AddAnnotationInput` - `PagePainter`, `PagePaint` +- `createScrollController`, `ScrollController`, `ScrollControllerEvents`, + `ScrollControllerOptions`, `ScrollPoint`, `ScrollHighlight`, `ScrollPageRect` + (scrolled reading, below) +- `defaultScrollPhysics`, `ScrollPhysics`, `ScrollSimulation` Supporting exports: @@ -255,6 +259,131 @@ frame with `progress` from 0 at rest to 1 on arrival, for chrome that follows th page. In React, `useReaderTransition(controller)` gives `{ isTransitioning, direction }` and `useReaderTransitionProgress(controller)` the progress. +## Scrolled Reading + +A reader created with `spread: 'scrolled'` lays each chapter out as one strip +read through windows the size of the viewport (see the reader API). The page +controller turns them like pages; `createScrollController(reader, canvas, +options)` shows them end to end instead, on its own WebGL2 canvas: + +```ts +const reader = await createReader(bytes, canvas, { + width, + height, + spread: 'scrolled', + pinnedFontPolicy, +}); +const scroll = createScrollController(reader, canvas); +scroll.mount(container); +scroll.on('position', ({ locator }) => save(locator)); +scroll.on('tap', ({ target }) => { + if (target?.targetLocator) void scroll.scrollToLocator(target.targetLocator); +}); +``` + +The view draws each window it shows at whole device pixels, at the place the +engine's window rule gives it, so neighbouring windows meet without a seam; it +keeps a picture of the windows shown and one either side, and paints the ones +beside ahead, which has the reader load them. The window under the reading line +(the content box's block start: its top, or its right edge for a book that +scrolls leftwards) is the one the reader reads: the controller tells the reader +when it changes (`window`), and once the strip rests reports the locator of the +line at the reading line (`position`). + +How the strip moves is the app's. The built-in input (`input: true`, the +default) scrolls with the wheel and the keys and drags and flings with a touch or +a pen; `physics` takes any `ScrollPhysics` (`drag` resists past the ends, +`release` returns the motion a release starts), `defaultScrollPhysics` being a +browser-like glide with a rubber band. An app that drives the strip itself turns +the input off and calls `grab`, `follow`, `release`, `scrollTo`, `scrollBy`, +`scrollToSpread` and `scrollToLocator` (`pageLength` is how far a page moves the +strip, as PageDown moves it: the view's length less two lines); `pointAt` and `targetAt` say what is under +a point, and `viewRects` where page rects (a target's bounds, a note's lines) are +on the view, relative to `canvas`. Offsets are CSS pixels along the axis from the book's block start. + +The view selects text through the reader's own text selection (`selection: +true`, the default): with the built-in input a mouse drag selects from where it +was pressed, on from one window into the next, a double press selects a word and +a triple press a paragraph, a touch held still selects the word under it and +then follows the finger instead of scrolling, a drag held near either end of the view scrolls the +strip on under it, and a press that does not move clears the selection and taps. While a selection +is being made the whole-book layout waits, as it does for the page controller, +so a layout landing mid-drag does not end the gesture. +A Shift chord moves the selection's focus as the page controller's does, its +arrow keys read by the writing mode of the focus's line, and the strip follows a +focus that moves off screen. `selectionHandles()` and `beginSelectionHandleDrag` +give the selection's ends and drag one by a handle the app draws, in the shapes +the page controller gives (the `selection` event carries the handles too); a +handle held near either end of the view scrolls the strip on. +What is selected is drawn over each window that shows it and moves with it; the +`selection` event gives the range, its text and its rects where the view shows +them, `selectionRects()` the rects once the strip has moved, and +`clearSelection()` lets it go. A selection is found again in a new layout, as a +browser keeps one through a resize. `setHighlights(highlights)` draws what the +app highlights over the same windows, above the selection: each +`ScrollHighlight` is page rects (`pageIndex` and a rect in the page's content box, +as the reader's interactions give them), a colour, a `zIndex` and a `mark` +(filled, or a line along the rect's bottom edge), so a search's matches and a +book's annotations show in a scrolled view as the app keeps them. The view says +which of them the pointer is on, the one drawn on top: a `tap` carries it as +`highlight` (its `id` and `bounds` in screen coordinates, for a popover), and +`highlightHover` tells the one under a resting mouse each time it changes or the +strip moves it, null once the mouse leaves or presses — what an annotation's +tooltip and its editor need, as a page view's `annotationHover` and +`annotationClick` give them. `colors` changes the selection's colour. + +With `a11y: { enabled: true }` the view keeps a hidden mirror of the text on +screen for screen readers, in `a11y.container` or beside the view: once the strip +rests it reads the semantics of the pages of the windows shown and puts them in +after the next frame, and a link activated in the mirror is a `tap` on its +target. + +The strip has its windows from the moment the view is made, so it can be moved +(`scrollToLocator`) before it is mounted; mounting it again where it already is +changes nothing, so a ref called on every render may mount it. Through every new layout it stays on +the window the reader reads, as far into it as the reading line was: the +reader's active spread, which a chapter laid out alone, the whole book and +another size each number anew. + +### Pages And Scrolled On One Reader + +A book read in pages and scrolled is one reader: the page controller hands it +to a scroll view and takes it back, and nothing is opened again. `suspend()` +takes the controller's canvas off the page, lets its page buffers go, and stops +it painting, turning, taking keys and answering the reader's layouts; its +annotations, search and stored position stay, and `resize`, `setTheme`, +`setTypography` and `setSpreadMode` still lay the reader out for the view that +has it. `resume()` takes the reader back as it is laid out then, at its active +spread, and puts the canvas back where it was mounted last (a `mount` while +suspended names that place). `useRitoScrollView` in `@ritojs/react` runs the +whole turn; without React: + +```ts +const nextLayout = (reader: Reader) => + new Promise((resolve) => { + const stop = reader.onLayoutCommitted(() => { + stop(); + resolve(); + }); + }); + +// To scrolled, from where the pages are. +const from = pages.position?.sourceLocator; +pages.suspend(); +pages.reader.setSpreadMode('scrolled'); +await nextLayout(pages.reader); +const scroll = createScrollController(pages.reader, document.createElement('canvas')); +if (from) await scroll.scrollToLocator(from); +scroll.mount(container); + +// Back to pages, from where the view was read (its last `position`). +scroll.dispose(); +await pages.reader.navigateToLocator?.(read); +pages.reader.setSpreadMode('double'); +await nextLayout(pages.reader); +pages.resume(); +``` + ## Drawing On Pages `paintPage` in `ControllerOptions` draws over each page as part of it: a page diff --git a/docs/integrations/react.md b/docs/integrations/react.md index 1fb81f3c3..eb7a71146 100644 --- a/docs/integrations/react.md +++ b/docs/integrations/react.md @@ -15,18 +15,35 @@ import { useRitoReader, Reader } from '@ritojs/react'; Hooks: - `useRitoReader` +- `useRitoScrollReader` +- `useRitoScrollView` - `useSelection` +- `useScrollSelectionHandles` - `useSearch` - `useAnnotations` - `useReadingPosition` - `useContainerSize` - `useControllerEvent` +`ScrollSelectionHandles({ controller, rootRef, touchInput })` draws a scrolled +view's selection handles as `Reader` draws the page controller's, placed over the +view's canvas inside `rootRef` and moved with the strip. +`useScrollSelectionHandles(controller)` gives those ends where the view shows +them (`start`, `end`, and `focusEdge` naming the focus), following the strip and +null while nothing is selected or a gesture still selects: what a toolbar over a +scrolled selection stands on, as `useSelection`'s `focusRect` is for pages. + Types: - `UseRitoReaderOptions` - `RitoReaderState` - `RitoReaderActions` +- `UseRitoScrollReaderOptions` +- `RitoScrollReaderState` +- `RitoScrollReaderActions` +- `UseRitoScrollViewOptions` +- `RitoScrollViewState` +- `RitoScrollViewActions` - `SelectionState` - `SearchState` - `AnnotationsState` @@ -146,6 +163,56 @@ use `controller.addFontFaces()`; `controller.fonts()` lists every family a `font-family` choice can name, which is what a font picker should be built from. See [Fonts](../api/reader.md#fonts) for how families resolve. +## `useRitoScrollReader` + +`useRitoScrollReader({ reader, scroll, initialLocator })` owns a scrolled reader +(the book laid out with `spread: 'scrolled'` whatever `reader.spread` says) and +its kit scroll view. Pass `containerRef` as the ref of the element the view is +shown in; `load(data)` opens a book, letting go of the one before, and shows +`initialLocator`'s line at the reading line once the book is laid out; `unload()` +lets it go. The state +follows the view: `progress` (0 to 1 through the book), `position` (the locator +at the reading line as of the strip's last rest, to save and reopen at) and +`selection` (the `selection` event while something is selected, null +otherwise), with `controller` for everything else the view does +(`scrollToLocator`, `setHighlights`, `on('tap')`). + +```tsx +const { containerRef, load, progress, controller } = useRitoScrollReader({ + reader: { width, height, pinnedFontPolicy }, + initialLocator: saved, +}); +useEffect( + () => + controller?.on('tap', ({ target }) => { + if (target?.targetLocator) void controller.scrollToLocator(target.targetLocator); + }), + [controller], +); +return
; +``` + +## `useRitoScrollView` + +`useRitoScrollView(controller, { active, paged, scroll })` reads the book a +page controller shows scrolled, on the same reader: while `active`, the +controller hands the reader over (`suspend()`), the reader is laid out scrolled +and a scroll view shows it from where the pages were; turned off, the reader is +paged again as `paged` says (`'single'` or `'double'`) from where the view was +read, and the controller takes it back (`resume()`). Size, theme and typography +reach the reader through the controller as before, and the view follows each +layout. The state is the view's (`controller`, `progress`, `position`, +`selection`), with `isLoading` while the scrolled layout is made (only after a +short delay) and `error`; pass `containerRef` as the ref of the element the view +is shown in. When the controller is replaced (another book opened), the view is +let go with it. + +```tsx +const rito = useRitoReader({ reader: { width, height, pinnedFontPolicy } }); +const scroll = useRitoScrollView(rito.controller, { active: scrolled, paged: 'double' }); +return scrolled ?
: ; +``` + ## `useReaderTransition` `useReaderTransition(controller)` returns `{ isTransitioning, direction }` from the diff --git a/package.json b/package.json index ebfcb89a1..223e813ea 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "devDependencies": { "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.64.1", "@types/node": "^25.5.0", "@types/pngjs": "^6.0.5", "@vitest/coverage-v8": "^4.1.12", @@ -80,7 +80,7 @@ "prettier": "^3.8.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.0", - "vite": "^6.4.3", + "vite": "^6.4.5", "vite-tsconfig-paths": "^6.1.1" } } diff --git a/packages/kit/package.json b/packages/kit/package.json index c2ae4b568..6add6fdb6 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -61,6 +61,7 @@ "devDependencies": { "@ritojs/core-wasm": "workspace:*", "tsdown": "^0.21.7", + "happy-dom": "^21.0.4", "@vitest/coverage-v8": "^4.1.12", "vitest": "^4.1.12" } diff --git a/packages/kit/src/controller/bootstrap.ts b/packages/kit/src/controller/bootstrap.ts index 7334496bd..501083890 100644 --- a/packages/kit/src/controller/bootstrap.ts +++ b/packages/kit/src/controller/bootstrap.ts @@ -77,7 +77,8 @@ function createRuntimeNavigation( contentRenderer: ContentRenderer, ) { return createNavigation({ - getReader: () => internals.reader, + // Nothing turns while another view has the reader. + getReader: () => (internals.suspended ? null : internals.reader), getCurrentSpread: () => internals.currentSpread, setCurrentSpread: (index, reason) => { commitCurrentSpread(internals, index, reason); @@ -136,6 +137,9 @@ function wireRuntimeEvents( if (typeof internals.reader.onLayoutCommitted === 'function') { disposables.add( internals.reader.onLayoutCommitted((activeSpreadIndex) => { + // The view that has the reader answers its layouts; this one takes + // the reader's layout up again when it resumes. + if (internals.suspended) return; commitLayoutChange(internals, emitter, runtime, undefined, activeSpreadIndex); scheduleControllerPrerender(internals, runtime, contentRenderer); nav.notifyLayoutCommitted(); diff --git a/packages/kit/src/controller/core/internals.ts b/packages/kit/src/controller/core/internals.ts index a037fe7ba..315d8cf0a 100644 --- a/packages/kit/src/controller/core/internals.ts +++ b/packages/kit/src/controller/core/internals.ts @@ -22,4 +22,10 @@ export interface Internals { * by the controller's own initial `positionChange` event. */ restoreCompleted: boolean; + /** + * `true` while another view has the reader (see + * {@link ReaderController.suspend}): the controller paints nothing, + * turns no page and answers none of the reader's layouts. + */ + suspended: boolean; } diff --git a/packages/kit/src/controller/core/wiring-deps.ts b/packages/kit/src/controller/core/wiring-deps.ts index ec9ab928f..583745a31 100644 --- a/packages/kit/src/controller/core/wiring-deps.ts +++ b/packages/kit/src/controller/core/wiring-deps.ts @@ -29,6 +29,8 @@ export interface WiringDeps { * the persisted position with the controller's own initial spread-0 event. */ hasRestored: () => boolean; + /** Whether another view has the reader: what it reports meanwhile is not this controller's to answer. */ + isSuspended: () => boolean; /** Continue a deferred navigation once an async content slot is ready. */ notifyNavigationContentReady: (index: number) => void; /** Sync viewport-sized buffers before painting a current-spread visual refresh. */ @@ -65,6 +67,7 @@ export function buildWiringDeps( nav.navigateToLocator(locator); }, hasRestored: () => internals.restoreCompleted, + isSuspended: () => internals.suspended, notifyNavigationContentReady: (i) => { nav.notifyContentReady(i); }, diff --git a/packages/kit/src/controller/facade/index.ts b/packages/kit/src/controller/facade/index.ts index d913ec054..e552f9f3a 100644 --- a/packages/kit/src/controller/facade/index.ts +++ b/packages/kit/src/controller/facade/index.ts @@ -9,6 +9,7 @@ import type { RuntimeComponents, } from './types'; import { buildLifecycle } from './lifecycle'; +import { buildSuspension } from './suspension'; import { buildReaderProxies } from './reader-proxies'; import { buildFontActions } from './font-actions'; import { buildLayoutActions } from './layout-actions'; @@ -22,6 +23,7 @@ import { buildPositionActions } from './position-actions'; import { buildMisc } from './misc-actions'; import { buildNavigationActions } from './navigation-actions'; import { buildWiringDeps } from '../core/wiring-deps'; +import type { ContentRenderer } from '../../painter/buffer-pool'; export type { Internals } from './types'; export { syncCanvasSize } from './lifecycle'; @@ -35,12 +37,23 @@ export function buildController( modeManager: ModeManager, nav: Nav, canvas: HTMLCanvasElement, + contentRenderer: ContentRenderer, ): ReaderController { const controller = {} as ReaderController; const lifecycleDeps = buildWiringDeps(internals, emitter, runtime.frameDriver, canvas, nav); + const lifecycle = buildLifecycle(disposables, runtime, lifecycleDeps); defineSlice( controller, - buildLifecycle(disposables, runtime, lifecycleDeps), + lifecycle.slice, + buildSuspension( + internals, + emitter, + runtime, + nav, + keyboard, + lifecycle.mounting, + contentRenderer, + ), buildNavigationActions(nav), buildLayoutActions(internals, emitter, runtime, disposables), buildFontActions(internals), diff --git a/packages/kit/src/controller/facade/layout-actions.ts b/packages/kit/src/controller/facade/layout-actions.ts index de03ac7e4..3bbe83dcf 100644 --- a/packages/kit/src/controller/facade/layout-actions.ts +++ b/packages/kit/src/controller/facade/layout-actions.ts @@ -84,6 +84,11 @@ function applyRenderScale( ): void { requireRenderScale(scale); if (scale === internals.renderScale) return; + // Shown at that scale once the controller resumes. + if (internals.suspended) { + internals.renderScale = scale; + return; + } const carried = carrySelection(internals); invalidateSelectionForLayout(internals); internals.renderScale = scale; @@ -107,6 +112,9 @@ function refreshLayoutWhenChanged( anchor: ReadingPosition | null, ): boolean { if (!changed) return false; + // The view that has the reader shows the layout; this one takes it up + // when it resumes. + if (internals.suspended) return true; commitLayoutChange(internals, emitter, runtime, anchor); return true; } diff --git a/packages/kit/src/controller/facade/lifecycle.ts b/packages/kit/src/controller/facade/lifecycle.ts index 2a8d6495d..a8593bd2c 100644 --- a/packages/kit/src/controller/facade/lifecycle.ts +++ b/packages/kit/src/controller/facade/lifecycle.ts @@ -37,19 +37,34 @@ function shouldResizeSurface( ); } +/** Takes the canvas off the page while another view has the reader, and puts it back. */ +export interface MountSuspension { + /** Whether the controller was disposed: there is nothing to hand over or take back. */ + readonly disposed: boolean; + detach(): void; + attach(): void; +} + export function buildLifecycle( disposables: Disposables, runtime: RuntimeComponents, deps: WiringDeps, -): LifecycleSlice { +): { readonly slice: LifecycleSlice; readonly mounting: MountSuspension } { const state: LifecycleState = { disposed: false, mountedContainer: undefined, mountDisposables: createDisposableCollection(), restoreSurfaceMarker: undefined, + detached: false, + resumeContainer: undefined, }; - return { + const slice: LifecycleSlice = { mount(container: HTMLElement): void { + if (state.detached && !state.disposed) { + // Mounted there when the controller resumes. + state.resumeContainer = container; + return; + } mountLifecycle(state, container, runtime, deps); }, dispose(): void { @@ -58,6 +73,36 @@ export function buildLifecycle( disposeLifecycle(state, disposables, runtime, deps); }, }; + return { slice, mounting: mountSuspension(state, runtime, deps) }; +} + +function mountSuspension( + state: LifecycleState, + runtime: RuntimeComponents, + deps: WiringDeps, +): MountSuspension { + return { + get disposed() { + return state.disposed; + }, + detach(): void { + if (state.detached || state.disposed) return; + state.detached = true; + state.resumeContainer = state.mountedContainer; + state.mountedContainer = undefined; + // The mirror for screen readers goes with the canvas. + state.mountDisposables.disposeAll(); + state.mountDisposables = createDisposableCollection(); + runtime.surface.canvas.remove(); + }, + attach(): void { + if (!state.detached || state.disposed) return; + state.detached = false; + const container = state.resumeContainer; + state.resumeContainer = undefined; + if (container) mountLifecycle(state, container, runtime, deps); + }, + }; } interface LifecycleState { @@ -65,6 +110,10 @@ interface LifecycleState { mountedContainer: HTMLElement | undefined; mountDisposables: DisposableCollection; restoreSurfaceMarker: (() => void) | undefined; + /** Off the page while another view has the reader. */ + detached: boolean; + /** Where the canvas goes back when the controller resumes. */ + resumeContainer: HTMLElement | undefined; } function mountLifecycle( diff --git a/packages/kit/src/controller/facade/suspension.ts b/packages/kit/src/controller/facade/suspension.ts new file mode 100644 index 000000000..9f5dd5738 --- /dev/null +++ b/packages/kit/src/controller/facade/suspension.ts @@ -0,0 +1,63 @@ +import { holdKeyboardManagerInput } from '../../keyboard/input-state'; +import type { ContentRenderer } from '../../painter/buffer-pool'; +import { abortTransition } from '../core/transition-lifecycle'; +import { scheduleControllerPrerender } from '../runtime-frame'; +import { commitLayoutChange } from './layout-actions'; +import type { MountSuspension } from './lifecycle'; +import type { + Emitter, + Internals, + Keyboard, + Nav, + RuntimeComponents, + SuspensionSlice, +} from './types'; + +/** Hands the reader over to another view and takes it back (see `ReaderController.suspend`). */ +export function buildSuspension( + internals: Internals, + emitter: Emitter, + runtime: RuntimeComponents, + nav: Nav, + keyboard: Keyboard, + mounting: MountSuspension, + contentRenderer: ContentRenderer, +): SuspensionSlice { + let releases: (() => void)[] = []; + return { + suspend(): void { + if (internals.suspended || mounting.disposed) return; + // What was under way ends here: a turn, a selection, a queued jump. + nav.supersedeForPositionIntent(); + abortTransition(runtime.td, emitter); + internals.engines.selection.clear(); + emitter.emit('annotationHover', { annotation: null, x: 0, y: 0 }); + internals.suspended = true; + runtime.prerenderScheduler.pause(); + releases = [ + runtime.frameDriver.suspend(), + holdKeyboardManagerInput(keyboard), + () => { + runtime.prerenderScheduler.resume(); + }, + ]; + mounting.detach(); + runtime.pool.resize(1, 1, 1); + runtime.surface.release(); + }, + resume(): void { + if (!internals.suspended || mounting.disposed) return; + internals.suspended = false; + for (const release of releases) release(); + releases = []; + // The layout the reader has now, taken up as if it had just committed. + commitLayoutChange(internals, emitter, runtime, null, internals.reader.activeSpreadIndex); + scheduleControllerPrerender(internals, runtime, contentRenderer); + nav.notifyLayoutCommitted(); + mounting.attach(); + }, + get suspended() { + return internals.suspended && !mounting.disposed; + }, + }; +} diff --git a/packages/kit/src/controller/facade/types.ts b/packages/kit/src/controller/facade/types.ts index c07abf57d..a36103d56 100644 --- a/packages/kit/src/controller/facade/types.ts +++ b/packages/kit/src/controller/facade/types.ts @@ -17,6 +17,7 @@ export type ModeManager = ReturnType; export type Nav = ReturnType; export type LifecycleSlice = Pick; +export type SuspensionSlice = Pick; export type NavigationActionsSlice = Pick< ReaderController, 'goToSpread' | 'nextSpread' | 'prevSpread' | 'navigateToTocEntry' | 'jumpToSpread' diff --git a/packages/kit/src/controller/geometry/coordinate-mapper.ts b/packages/kit/src/controller/geometry/coordinate-mapper.ts index bbde48cf1..65e49933d 100644 --- a/packages/kit/src/controller/geometry/coordinate-mapper.ts +++ b/packages/kit/src/controller/geometry/coordinate-mapper.ts @@ -12,7 +12,7 @@ * Every offset formula lives here — no other file should hard-code margin/gap arithmetic. */ -import type { LayoutConfig, Spread } from '@ritojs/core'; +import type { LayoutConfig, ReaderSpreadMode, Spread } from '@ritojs/core'; import type { Rect } from '../../interaction/index'; import { resolveSpreadPage } from './page-resolution'; import { @@ -35,7 +35,7 @@ export interface LayoutGeometry { readonly marginBottom: number; readonly marginLeft: number; readonly spreadGap: number; - readonly spreadMode: 'single' | 'double'; + readonly spreadMode: ReaderSpreadMode; } export interface PageGeometry { diff --git a/packages/kit/src/controller/index.ts b/packages/kit/src/controller/index.ts index 0e9936b2b..b5766bb39 100644 --- a/packages/kit/src/controller/index.ts +++ b/packages/kit/src/controller/index.ts @@ -91,6 +91,7 @@ function constructController( modeManager, nav, canvas, + contentRenderer, ); construction.commit(); return controller; diff --git a/packages/kit/src/controller/runtime-construction.ts b/packages/kit/src/controller/runtime-construction.ts index 08e50db3e..1773ba04a 100644 --- a/packages/kit/src/controller/runtime-construction.ts +++ b/packages/kit/src/controller/runtime-construction.ts @@ -205,6 +205,7 @@ function createControllerInternals(reader: Reader, options: ControllerOptions): positionPersistence: createPositionPersistence(options.positionStorage), pendingPositionAction: undefined, restoreCompleted: false, + suspended: false, }; } catch (error: unknown) { const annotationStore = coordState.annotationStore; diff --git a/packages/kit/src/controller/types.ts b/packages/kit/src/controller/types.ts index 411e5892e..612733ebe 100644 --- a/packages/kit/src/controller/types.ts +++ b/packages/kit/src/controller/types.ts @@ -6,6 +6,7 @@ import type { ReaderExactSourceRangeRequest, ReaderMargin, ReaderLocator, + ReaderSpreadMode, Spread, TocEntry, } from '@ritojs/core'; @@ -93,6 +94,24 @@ export interface ReaderController { mount(container: HTMLElement): void; /** Clean up all engines, DOM elements, and listeners. Does NOT dispose the Reader. */ dispose(): void; + /** + * Hands the reader over to another view of it (a scroll view, say) until + * {@link resume}: the controller takes its canvas off the page, lets its + * page buffers go, and paints, turns, takes keys and answers the + * reader's layouts no more. Its annotations, search and stored position + * stay; `resize`, `setTheme`, `setTypography` and `setSpreadMode` still + * lay the reader out, for the view that has it. A `mount` meanwhile + * names where the canvas goes back. + */ + suspend(): void; + /** + * Takes the reader back as it is laid out now, at its active spread: the + * canvas goes back in the container it was mounted in last, and the + * spread is painted afresh. + */ + resume(): void; + /** Whether another view has the reader (see {@link suspend}); false once disposed. */ + readonly suspended: boolean; readonly reader: Reader; readonly metadata: PackageMetadata; @@ -116,7 +135,7 @@ export interface ReaderController { /** Re-paginate with new viewport dimensions (and optional margins). Also syncs canvas size using renderScale. */ resize(width: number, height: number, margin?: ReaderMargin): void; - setSpreadMode(mode: 'single' | 'double'): void; + setSpreadMode(mode: ReaderSpreadMode): void; setTheme(options: ReaderThemeOptions): void; /** Paints every page again, `paintPage` included, for when what it draws has changed. */ repaintPages(): void; diff --git a/packages/kit/src/controller/wiring/engine-events.ts b/packages/kit/src/controller/wiring/engine-events.ts index 53190fbd5..79c8a59b8 100644 --- a/packages/kit/src/controller/wiring/engine-events.ts +++ b/packages/kit/src/controller/wiring/engine-events.ts @@ -116,7 +116,7 @@ function wireSearchEvents(deps: WiringDeps, disposables: DisposableCollection): disposables.add( engines.search.onResultsChange((results) => { replaceNativeSearchResults(deps.coordState, results); - scheduleNativeSearchForCurrentSpread(deps); + if (!deps.isSuspended()) scheduleNativeSearchForCurrentSpread(deps); emitContainedSearchEvent( emitter, 'searchResults', @@ -130,7 +130,7 @@ function wireSearchEvents(deps: WiringDeps, disposables: DisposableCollection): disposables.add( engines.search.onActiveResultChange((idx) => { const results = engines.search.getResults(); - scheduleNativeSearchForCurrentSpread(deps); + if (!deps.isSuspended()) scheduleNativeSearchForCurrentSpread(deps); emitContainedSearchEvent( emitter, 'searchActiveChange', @@ -172,24 +172,8 @@ function wireAnnotationStoreEvents(deps: WiringDeps, disposables: DisposableColl disposables.add( store.onChange((records) => { deps.emitter.emit('annotationHover', { annotation: null, x: 0, y: 0 }); - refreshNativeAnnotations(deps.reader, deps.coordState); - const spread = deps.reader.spreads[deps.getCurrentSpread()]; - if (spread) { - scheduleNativeAnnotationsForSpread( - spread, - deps.reader, - deps.coordState, - () => { - deps.frameDriver.markAllOverlaysDirty(); - }, - (error) => { - deps.emitter.emit('error', { - message: error instanceof Error ? error.message : String(error), - source: 'native-annotation-geometry', - }); - }, - ); - } + // Where they are on the page is read again when the controller resumes. + if (!deps.isSuspended()) placeAnnotations(deps); deps.emitter.emit('annotationsChange', { annotations: records }); // Annotations are global — invalidate ALL slots deps.frameDriver.markAllOverlaysDirty(); @@ -203,6 +187,27 @@ function wireAnnotationStoreEvents(deps: WiringDeps, disposables: DisposableColl } } +function placeAnnotations(deps: WiringDeps): void { + refreshNativeAnnotations(deps.reader, deps.coordState); + const spread = deps.reader.spreads[deps.getCurrentSpread()]; + if (spread) { + scheduleNativeAnnotationsForSpread( + spread, + deps.reader, + deps.coordState, + () => { + deps.frameDriver.markAllOverlaysDirty(); + }, + (error) => { + deps.emitter.emit('error', { + message: error instanceof Error ? error.message : String(error), + source: 'native-annotation-geometry', + }); + }, + ); + } +} + function containAnnotationStorageFailure(error: unknown, deps: WiringDeps): void { try { deps.emitter.emit('error', { diff --git a/packages/kit/src/controller/wiring/keyboard-selection-map.ts b/packages/kit/src/controller/wiring/keyboard-selection-map.ts index f5e3e199e..c698222c3 100644 --- a/packages/kit/src/controller/wiring/keyboard-selection-map.ts +++ b/packages/kit/src/controller/wiring/keyboard-selection-map.ts @@ -1,11 +1,39 @@ -import type { ReaderTextSelectionMovement } from '@ritojs/core'; +import type { ReaderTextSelectionMovement, ReaderWritingMode } from '@ritojs/core'; +/** + * The selection movement a Shift chord asks for with the selection's focus + * on a line of `writingMode`. An arrow key is first read as the key on that + * side of the line, as the browser transposes arrows by the focus's writing + * mode before interpreting them: on a vertical-rl line down moves along the + * line, up back along it, left on to the next line and right back to the + * previous one, with the same modifiers. + */ export function keyboardSelectionMovement( event: Pick, apple: boolean, + writingMode: ReaderWritingMode = 'horizontal-tb', ): ReaderTextSelectionMovement | null { if (!event.shiftKey || event.isComposing) return null; - return apple ? appleMovement(event) : otherPlatformMovement(event); + const read = { + key: transposedArrowKey(event.key, writingMode), + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }; + return apple ? appleMovement(read) : otherPlatformMovement(read); +} + +/** Each arrow key read on a vertical-rl line as the horizontal key on its side. */ +const VERTICAL_RL_ARROWS: Readonly> = { + ArrowLeft: 'ArrowDown', + ArrowRight: 'ArrowUp', + ArrowUp: 'ArrowLeft', + ArrowDown: 'ArrowRight', +}; + +function transposedArrowKey(key: string, writingMode: ReaderWritingMode): string { + if (writingMode === 'horizontal-tb') return key; + return VERTICAL_RL_ARROWS[key] ?? key; } export function isAppleKeyboardPlatform(value: Navigator): boolean { diff --git a/packages/kit/src/controller/wiring/keyboard-selection.ts b/packages/kit/src/controller/wiring/keyboard-selection.ts index 643203d6c..b59a2b22a 100644 --- a/packages/kit/src/controller/wiring/keyboard-selection.ts +++ b/packages/kit/src/controller/wiring/keyboard-selection.ts @@ -84,7 +84,7 @@ function routeKeyboardSelection( ) { return; } - const movement = keyboardSelectionMovement(event, apple); + const movement = keyboardSelectionMovement(event, apple, state.owner.focusWritingMode()); if (!movement) return; event.preventDefault(); event.stopPropagation(); diff --git a/packages/kit/src/controller/wiring/position.ts b/packages/kit/src/controller/wiring/position.ts index 46a112293..24657968b 100644 --- a/packages/kit/src/controller/wiring/position.ts +++ b/packages/kit/src/controller/wiring/position.ts @@ -10,8 +10,9 @@ export function wirePositionTracker(deps: WiringDeps, disposables: DisposableCol tracker.onPositionChange((position) => { // A position taken while a chapter is laid out alone projects onto // that chapter's own page numbers: hosts hear the one the whole book - // gives, which follows its commit. - if (showsChapterAlone(deps.reader)) return; + // gives, which follows its commit. One taken while another view has + // the reader is that view's to tell. + if (showsChapterAlone(deps.reader) || deps.isSuspended()) return; deps.coordState.publishedPosition = position; emitter.emit('positionChange', { position }); if (!deps.hasRestored()) return; diff --git a/packages/kit/src/controller/wiring/spread.ts b/packages/kit/src/controller/wiring/spread.ts index cc1bd1fc7..8ddd5a79f 100644 --- a/packages/kit/src/controller/wiring/spread.ts +++ b/packages/kit/src/controller/wiring/spread.ts @@ -75,7 +75,7 @@ function updatePosition( export function wireSpreadRendered(deps: WiringDeps, disposables: DisposableCollection): void { disposables.add( deps.reader.onSpreadRendered((idx, spread) => { - if (idx !== deps.getCurrentSpread()) return; + if (deps.isSuspended() || idx !== deps.getCurrentSpread()) return; const coordinated = coordinateOnSpreadRendered( idx, spread, @@ -96,11 +96,12 @@ export function wireSpreadRendered(deps: WiringDeps, disposables: DisposableColl if (typeof deps.reader.onSpreadContentInvalidated === 'function') { disposables.add( deps.reader.onSpreadContentInvalidated((idx) => { - refreshInvalidatedSpread(idx, deps); + if (!deps.isSuspended()) refreshInvalidatedSpread(idx, deps); }), ); } deps.coordState.nativeTargetHold.reload = () => { + if (deps.isSuspended()) return; const spread = deps.reader.spreads[deps.getCurrentSpread()]; if (deps.reader.interactions?.enabled && spread) scheduleNativeTargetLoad(spread, deps); }; diff --git a/packages/kit/src/driver/frame-driver-holds.ts b/packages/kit/src/driver/frame-driver-holds.ts new file mode 100644 index 000000000..414b58c3b --- /dev/null +++ b/packages/kit/src/driver/frame-driver-holds.ts @@ -0,0 +1,67 @@ +import type { FrameDriver, FrameDriverState } from './frame-driver'; + +/** The frame driver's holds: the first composite held back, and every one while suspended. */ +export function compositeHolds( + state: FrameDriverState, + driver: () => FrameDriver, +): Pick { + return { + holdFirstComposite(): (() => void) | null { + return holdFirstComposite(driver(), state); + }, + suspend(): () => void { + return suspendComposites(state); + }, + }; +} + +function holdFirstComposite(driver: FrameDriver, state: FrameDriverState): (() => void) | null { + if (state.disposed || state.firstCompositeStarted) return null; + state.firstCompositeHoldCount += 1; + if (state.rafId !== null) { + cancelScheduledFrame(state); + requestHeldComposite(state, 'scheduled'); + } + let released = false; + return () => { + if (released) return; + released = true; + releaseFirstCompositeHold(driver, state); + }; +} + +function suspendComposites(state: FrameDriverState): () => void { + state.suspendCount += 1; + cancelScheduledFrame(state); + state.heldCompositeRequest = 'none'; + let released = false; + return () => { + if (released) return; + released = true; + state.suspendCount -= 1; + }; +} + +export function requestHeldComposite( + state: FrameDriverState, + request: Exclude, +): void { + if (request === 'immediate' || state.heldCompositeRequest === 'none') { + state.heldCompositeRequest = request; + } +} + +function releaseFirstCompositeHold(driver: FrameDriver, state: FrameDriverState): void { + state.firstCompositeHoldCount = Math.max(0, state.firstCompositeHoldCount - 1); + if (state.firstCompositeHoldCount > 0 || state.disposed) return; + const request = state.heldCompositeRequest; + state.heldCompositeRequest = 'none'; + if (request === 'immediate') driver.compositeNow(); + else if (request === 'scheduled') driver.scheduleComposite(); +} + +export function cancelScheduledFrame(state: FrameDriverState): void { + if (state.rafId === null) return; + cancelAnimationFrame(state.rafId); + state.rafId = null; +} diff --git a/packages/kit/src/driver/frame-driver.ts b/packages/kit/src/driver/frame-driver.ts index 6081da2d7..aa733239b 100644 --- a/packages/kit/src/driver/frame-driver.ts +++ b/packages/kit/src/driver/frame-driver.ts @@ -5,6 +5,7 @@ import type { TransitionDriver } from './transition-driver'; import type { DrawInstruction } from './types'; import type { TransitionFrame, TransitionScene } from './effects'; import { createTransitionScene, paintTransition, scaledTouch } from './transition-scene'; +import { cancelScheduledFrame, compositeHolds, requestHeldComposite } from './frame-driver-holds'; export interface FrameDriverDeps { readonly surface: DisplaySurface; @@ -34,6 +35,12 @@ export interface FrameDriver { compositeNow(): void; /** Hold the not-yet-painted first composite until the returned release is called. */ holdFirstComposite(): (() => void) | null; + /** + * Draws nothing until the returned release is called: a composite asked + * for meanwhile is dropped, not deferred, since whoever releases it + * paints the page afresh. + */ + suspend(): () => void; /** * Has a painted effect do the work on the pages beside the one shown * that a turn would otherwise do in its first frame; nothing while a turn @@ -50,13 +57,14 @@ export interface FrameDriver { dispose(): void; } -interface FrameDriverState { +export interface FrameDriverState { rafId: number | null; lastFrameTime: number; disposed: boolean; firstCompositeStarted: boolean; firstCompositeHoldCount: number; heldCompositeRequest: 'none' | 'scheduled' | 'immediate'; + suspendCount: number; } export function createFrameDriver(deps: FrameDriverDeps): FrameDriver { @@ -67,6 +75,7 @@ export function createFrameDriver(deps: FrameDriverDeps): FrameDriver { firstCompositeStarted: false, firstCompositeHoldCount: 0, heldCompositeRequest: 'none', + suspendCount: 0, }; function onFrame(now: number): void { @@ -119,12 +128,10 @@ function createFrameDriverApi( compositeNow(deps, state); }, - holdFirstComposite(): (() => void) | null { - return holdFirstComposite(driver, state); - }, + ...compositeHolds(state, () => driver), warmTransition(): void { - if (state.disposed) return; + if (state.disposed || state.suspendCount > 0) return; warmTransition(deps); }, @@ -134,7 +141,7 @@ function createFrameDriverApi( }, markContentDirty(spreadIndex): void { - markContentDirty(deps, driver, spreadIndex); + markContentDirty(deps, state, driver, spreadIndex); }, markAllOverlaysDirty(): void { @@ -152,7 +159,7 @@ function createFrameDriverApi( } function scheduleComposite(state: FrameDriverState, onFrame: FrameRequestCallback): void { - if (state.disposed) return; + if (state.disposed || state.suspendCount > 0) return; if (state.firstCompositeHoldCount > 0) { requestHeldComposite(state, 'scheduled'); return; @@ -162,7 +169,7 @@ function scheduleComposite(state: FrameDriverState, onFrame: FrameRequestCallbac } function compositeNow(deps: FrameDriverDeps, state: FrameDriverState): void { - if (state.disposed) return; + if (state.disposed || state.suspendCount > 0) return; if (state.firstCompositeHoldCount > 0) { requestHeldComposite(state, 'immediate'); return; @@ -173,23 +180,14 @@ function compositeNow(deps: FrameDriverDeps, state: FrameDriverState): void { compositeFrame(deps, deps.transitionDriver.step(16)); } -function holdFirstComposite(driver: FrameDriver, state: FrameDriverState): (() => void) | null { - if (state.disposed || state.firstCompositeStarted) return null; - state.firstCompositeHoldCount += 1; - if (state.rafId !== null) { - cancelScheduledFrame(state); - requestHeldComposite(state, 'scheduled'); - } - let released = false; - return () => { - if (released) return; - released = true; - releaseFirstCompositeHold(driver, state); - }; -} - -function markContentDirty(deps: FrameDriverDeps, driver: FrameDriver, spreadIndex: number): void { +function markContentDirty( + deps: FrameDriverDeps, + state: FrameDriverState, + driver: FrameDriver, + spreadIndex: number, +): void { deps.pool.invalidateContentForSpread(spreadIndex); + if (state.suspendCount > 0) return; const slot = deps.pool.getSlotFor(spreadIndex); if (!slot) return; const ready = deps.pool.ensureContent(slot, deps.contentRenderer); @@ -198,30 +196,6 @@ function markContentDirty(deps: FrameDriverDeps, driver: FrameDriver, spreadInde else driver.compositeNow(); } -function requestHeldComposite( - state: FrameDriverState, - request: Exclude, -): void { - if (request === 'immediate' || state.heldCompositeRequest === 'none') { - state.heldCompositeRequest = request; - } -} - -function releaseFirstCompositeHold(driver: FrameDriver, state: FrameDriverState): void { - state.firstCompositeHoldCount = Math.max(0, state.firstCompositeHoldCount - 1); - if (state.firstCompositeHoldCount > 0 || state.disposed) return; - const request = state.heldCompositeRequest; - state.heldCompositeRequest = 'none'; - if (request === 'immediate') driver.compositeNow(); - else if (request === 'scheduled') driver.scheduleComposite(); -} - -function cancelScheduledFrame(state: FrameDriverState): void { - if (state.rafId === null) return; - cancelAnimationFrame(state.rafId); - state.rafId = null; -} - function ensureSlotReady(deps: FrameDriverDeps, position: SlotPosition): boolean { const contentReady = deps.pool.ensureContent(position, deps.contentRenderer); if (!contentReady) return false; diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index 81b38a9ce..93c1a693e 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -35,6 +35,18 @@ export type { } from './interaction/index'; export { parseReadingPosition } from './interaction/index'; +export { createScrollController } from './scroll/controller'; +export { defaultScrollPhysics } from './scroll/physics'; +export type { ScrollPhysics, ScrollSimulation } from './scroll/physics'; +export type { ScrollPoint } from './scroll/hit'; +export type { ScrollHighlight, ScrollPageRect } from './scroll/highlights'; +export type { ScrollHighlightHit } from './scroll/highlight-hit'; +export type { + ScrollController, + ScrollControllerEvents, + ScrollControllerOptions, +} from './scroll/types'; + export type { OverlayLayer, Rect } from './painter/types'; export { canDrawWithWebGl2, WebGlUnavailableError } from './painter/display-surface'; export type { TransitionDriverOptions } from './driver/types'; diff --git a/packages/kit/src/interaction/selection/native-adapter-gesture.ts b/packages/kit/src/interaction/selection/native-adapter-gesture.ts index 2fe8a033e..fe4a18643 100644 --- a/packages/kit/src/interaction/selection/native-adapter-gesture.ts +++ b/packages/kit/src/interaction/selection/native-adapter-gesture.ts @@ -22,6 +22,7 @@ export function registerNativeAdapterGestureOwner( ); registerSelectionKeyboardOwner(registered, { canExtend: () => native.canExtendKeyboardSelection(), + focusWritingMode: () => native.getSnapshot()?.range.focus.writingMode ?? 'horizontal-tb', begin: (movement) => native.beginKeyboardMovement(movement), }); return registered; diff --git a/packages/kit/src/interaction/selection/native-adapter-projection.ts b/packages/kit/src/interaction/selection/native-adapter-projection.ts index 3279efe44..19aeae4ed 100644 --- a/packages/kit/src/interaction/selection/native-adapter-projection.ts +++ b/packages/kit/src/interaction/selection/native-adapter-projection.ts @@ -18,7 +18,7 @@ export function projectNativeSelectionCaret( return projection.pageContentToSpread(caret.pageIndex, { x: caret.geometry.x, y: caret.geometry.y, - width: 0, + width: caret.geometry.width, height: caret.geometry.height, }); } diff --git a/packages/kit/src/interaction/selection/selection-keyboard-owner.ts b/packages/kit/src/interaction/selection/selection-keyboard-owner.ts index e63d187fe..b0c9a0243 100644 --- a/packages/kit/src/interaction/selection/selection-keyboard-owner.ts +++ b/packages/kit/src/interaction/selection/selection-keyboard-owner.ts @@ -1,9 +1,11 @@ -import type { ReaderTextSelectionMovement } from '@ritojs/core'; +import type { ReaderTextSelectionMovement, ReaderWritingMode } from '@ritojs/core'; import type { SelectionEngine } from './engine'; import type { NativeSelectionKeyboardCommand } from './native-types'; export interface SelectionKeyboardOwner { canExtend(): boolean; + /** The writing mode of the line the selection's focus is on. */ + focusWritingMode(): ReaderWritingMode; begin(movement: ReaderTextSelectionMovement): NativeSelectionKeyboardCommand | null; } diff --git a/packages/kit/src/keyboard/input-state.ts b/packages/kit/src/keyboard/input-state.ts index 4b8b2c71b..24b26a9f5 100644 --- a/packages/kit/src/keyboard/input-state.ts +++ b/packages/kit/src/keyboard/input-state.ts @@ -2,6 +2,8 @@ import type { KeyboardManager } from './types'; export interface KeyboardManagerInputState { enabled: boolean; + /** Holds taken by a controller that hands its view over: no keys reach it meanwhile. */ + held: number; disposed: boolean; readonly listeners: Set<(enabled: boolean) => void>; } @@ -9,7 +11,7 @@ export interface KeyboardManagerInputState { const states = new WeakMap(); export function createKeyboardManagerInputState(): KeyboardManagerInputState { - return { enabled: true, disposed: false, listeners: new Set() }; + return { enabled: true, held: 0, disposed: false, listeners: new Set() }; } export function registerKeyboardManagerInputState( @@ -35,7 +37,28 @@ export function subscribeKeyboardManagerInput( } export function acceptsKeyboardInputState(state: KeyboardManagerInputState): boolean { - return state.enabled && !state.disposed; + return state.enabled && state.held === 0 && !state.disposed; +} + +/** + * Keeps the manager from acting on keys until the returned release is + * called, whatever its host enables meanwhile: the host's own + * `setEnabled` is left as it was set. + */ +export function holdKeyboardManagerInput(manager: KeyboardManager): () => void { + const state = states.get(manager); + if (!state || state.disposed) return () => undefined; + const previous = acceptsKeyboardInputState(state); + state.held += 1; + publishInputStateChange(state, previous); + let released = false; + return () => { + if (released || state.disposed) return; + released = true; + const before = acceptsKeyboardInputState(state); + state.held -= 1; + publishInputStateChange(state, before); + }; } export function setKeyboardManagerEnabled(state: KeyboardManagerInputState, value: boolean): void { diff --git a/packages/kit/src/painter/display-surface.ts b/packages/kit/src/painter/display-surface.ts index 2bf1b05e8..d38a0d8ac 100644 --- a/packages/kit/src/painter/display-surface.ts +++ b/packages/kit/src/painter/display-surface.ts @@ -35,6 +35,11 @@ export interface DisplaySurface { setSize(cssWidth: number, cssHeight: number, dpr: number): void; /** Binds the canvas's own framebuffer and clears it. */ clear(): void; + /** + * Gives the drawing buffer back, down to one pixel, until the next + * `setSize` grows it again: for a canvas that shows nothing meanwhile. + */ + release(): void; /** * Calls back when the context was lost and has been restored, after the * compositor has made its programs again: everything else held in the @@ -108,21 +113,38 @@ export function createDisplaySurface(canvas: HTMLCanvasElement): DisplaySurface w = Math.round(cssWidth * dpr); h = Math.round(cssHeight * dpr); growDrawingBuffer(canvas, w, h, dpr); - // Zoomed out by the ratio, the canvas shows its drawing buffer at - // one pixel per device pixel; its box, the given CSS size, crops it - // to the bottom-left region. - canvas.style.zoom = String(1 / dpr); - canvas.style.width = `${String(cssWidth * dpr)}px`; - canvas.style.height = `${String(cssHeight * dpr)}px`; + cropDrawingBuffer(canvas, cssWidth, cssHeight, dpr); presentation.paintedAt(dpr); }, clear(): void { compositor.bind(null, w, h); compositor.clear(); }, + release(): void { + canvas.width = 1; + canvas.height = 1; + w = 0; + h = 0; + }, }; } +/** + * Zoomed out by the ratio, the canvas shows its drawing buffer at one + * pixel per device pixel; its box, the given CSS size, crops it to the + * bottom-left region. + */ +function cropDrawingBuffer( + canvas: HTMLCanvasElement, + cssWidth: number, + cssHeight: number, + dpr: number, +): void { + canvas.style.zoom = String(1 / dpr); + canvas.style.width = `${String(cssWidth * dpr)}px`; + canvas.style.height = `${String(cssHeight * dpr)}px`; +} + /** * Grows the drawing buffer to hold `width` × `height` device pixels and, * the first time, the whole screen at `dpr`: a window is never larger diff --git a/packages/kit/src/scroll/a11y.ts b/packages/kit/src/scroll/a11y.ts new file mode 100644 index 000000000..e56f097a2 --- /dev/null +++ b/packages/kit/src/scroll/a11y.ts @@ -0,0 +1,150 @@ +import type { + ReaderInteractionTarget, + ReaderPageSemantics, + ReaderSemanticNode, +} from '@ritojs/core'; +import { createA11yMirror } from '../interaction/dom/a11y-mirror'; +import { scheduleAfterNextFrame } from '../utils/idle-task'; +import { along, type ScrollView } from './view'; + +/** A window on screen: its spread and the page it shows. */ +interface Shown { + readonly spreadIndex: number; + readonly pageIndex: number; +} + +/** + * A hidden mirror of the text a scrolled view shows, for screen readers: + * once the strip rests, the semantics of the pages of the windows on + * screen, put in after the next frame so the frame that brought them is + * not held back. A link activated in the mirror taps its target, as a tap + * on the view does. Returns the unsubscribe. + */ +export function wireScrollA11y(view: ScrollView, parent: HTMLElement): () => void { + let installed = ''; + let generation = 0; + let pages = new WeakMap(); + let cancel: (() => void) | null = null; + const report = (error: unknown): void => { + console.error('[rito] the scroll view could not mirror its text for screen readers', error); + }; + const mirror = createA11yMirror(parent, { + onLinkActivate: (node) => activate(view, pages.get(node), node, report), + }); + const refresh = (): void => { + const shown = shownPages(view); + const key = shown.map((each) => each.pageIndex).join(','); + if (key === installed) return; + installed = key; + generation += 1; + const at = generation; + cancel?.(); + const interactions = view.reader.interactions; + const read = interactions?.getPageSemantics?.bind(interactions); + if (!interactions?.enabled || read === undefined) { + mirror.update([]); + return; + } + semanticsOf(read, shown).then(({ nodes, of }) => { + if (at !== generation) return; + cancel = scheduleAfterNextFrame(() => { + cancel = null; + if (at !== generation) return; + pages = of; + mirror.update(nodes); + }); + }, report); + }; + const stops = [ + view.emitter.on('rest', refresh), + view.reader.onLayoutCommitted(() => { + // The same pages of a new layout hold other text. + installed = ''; + }), + ]; + return () => { + generation += 1; + cancel?.(); + for (const stop of stops) stop(); + mirror.dispose(); + }; +} + +/** The windows on screen, in reading order. */ +function shownPages(view: ScrollView): Shown[] { + const { geometry } = view; + if (geometry === undefined) return []; + return geometry.visible(Math.round(view.motion.offset), along(view)).flatMap((window) => { + const pageIndex = view.reader.spreads[window.spreadIndex]?.leftPageIndex; + return pageIndex === undefined ? [] : [{ spreadIndex: window.spreadIndex, pageIndex }]; + }); +} + +/** The semantics of the pages shown, in order, and the window each node is on. */ +async function semanticsOf( + read: (pageIndex: number) => Promise, + shown: readonly Shown[], +): Promise<{ nodes: ReaderSemanticNode[]; of: WeakMap }> { + const results = await Promise.all(shown.map((each) => read(each.pageIndex))); + const of = new WeakMap(); + const nodes = results.flatMap((page, index) => { + const each = shown[index]; + if (page === undefined || each === undefined) return []; + bind(page.nodes, each, of); + return page.nodes; + }); + return { nodes, of }; +} + +function bind( + nodes: readonly ReaderSemanticNode[], + shown: Shown, + pages: WeakMap, +): void { + for (const node of nodes) { + pages.set(node, shown); + bind(node.children, shown, pages); + } +} + +/** A link activated in the mirror: its page's target taps, at its centre. */ +function activate( + view: ScrollView, + shown: Shown | undefined, + node: ReaderSemanticNode, + report: (error: unknown) => void, +): boolean { + const interactions = view.reader.interactions; + if (shown === undefined || !interactions?.enabled) return false; + interactions.getPageTargets(shown.pageIndex).then((page) => { + const target = page?.targets.find((candidate) => linksTo(candidate, node)); + if (target === undefined) return; + const { bounds } = target; + view.emitter.emit('tap', { + point: { + ...shown, + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2, + }, + target, + // A link activated in the mirror is the link, not a highlight over it. + highlight: null, + }); + }, report); + return true; +} + +function linksTo(target: ReaderInteractionTarget, node: ReaderSemanticNode): boolean { + const link = + target.kind === 'link' || target.kind === 'footnote' || target.kind === 'footnotePending'; + const { bounds } = target; + const other = node.bounds; + return ( + link && + target.href === node.href && + bounds.x <= other.x + other.width && + other.x <= bounds.x + bounds.width && + bounds.y <= other.y + other.height && + other.y <= bounds.y + bounds.height + ); +} diff --git a/packages/kit/src/scroll/composite.ts b/packages/kit/src/scroll/composite.ts new file mode 100644 index 000000000..ad4cd3fef --- /dev/null +++ b/packages/kit/src/scroll/composite.ts @@ -0,0 +1,64 @@ +import type { GlCompositor, QuadRect } from '../painter/gl/compositor'; +import type { ScrollGeometry, ScrollWindow } from './geometry'; + +/** + * Where window `window` lands on a `width` × `height` device-pixel view + * scrolled to device offset `offset`: below the one before it, or left of + * it for a book that scrolls leftwards, whose start is the view's right + * edge. + */ +export function windowRect( + geometry: ScrollGeometry, + window: ScrollWindow, + offset: number, + width: number, + height: number, +): QuadRect { + const along = window.start - offset; + return geometry.axis === 'down' + ? { x: 0, y: along, width, height: window.length } + : { x: width - along - window.length, y: 0, width: window.length, height }; +} + +/** What is drawn at a window's place on the view; undefined for nothing yet. */ +export type WindowLayer = (window: ScrollWindow, rect: QuadRect) => WebGLTexture | undefined; + +/** + * Draws the windows a view scrolled to `offset` shows, each at whole device + * pixels, the ones still loading left as the ground, and over each its + * overlay. Returns the windows it could not draw yet. + * + * Every window's picture is had first: painting one binds its own + * framebuffer, and a window drawn from its texture while that framebuffer + * is bound would be drawn into itself rather than onto the view. The view + * is bound once they all are. + */ +export function compositeWindows( + compositor: GlCompositor, + geometry: ScrollGeometry, + offset: number, + width: number, + height: number, + picture: WindowLayer, + overlay?: WindowLayer, +): readonly ScrollWindow[] { + const at = Math.round(offset); + const missing: ScrollWindow[] = []; + const shown: { rect: QuadRect; layers: WebGLTexture[] }[] = []; + const length = geometry.axis === 'down' ? height : width; + for (const window of geometry.visible(at, length)) { + const rect = windowRect(geometry, window, at, width, height); + const texture = picture(window, rect); + if (texture === undefined) { + missing.push(window); + continue; + } + const over = overlay?.(window, rect); + shown.push({ rect, layers: over === undefined ? [texture] : [texture, over] }); + } + compositor.bind(null, width, height); + for (const { rect, layers } of shown) { + for (const layer of layers) compositor.drawTexture(layer, rect); + } + return missing; +} diff --git a/packages/kit/src/scroll/controller.ts b/packages/kit/src/scroll/controller.ts new file mode 100644 index 000000000..9db31c42b --- /dev/null +++ b/packages/kit/src/scroll/controller.ts @@ -0,0 +1,265 @@ +import type { Reader, ReaderLocator } from '@ritojs/core'; +import { DEFAULT_HIGHLIGHT_COLORS, withHighlightColors } from '../controller/overlay/colors'; +import { createDisplaySurface } from '../painter/display-surface'; +import { createEmitter } from '../utils/event-emitter'; +import { wireScrollA11y } from './a11y'; +import { beginSelectionHandleDrag, selectionHandles } from './handles'; +import { scrollPointAt, scrollTargetAt } from './hit'; +import { wireScrollInput } from './input'; +import { movement, pageLength } from './movement'; +import { createScrollMotion } from './motion'; +import { createWindowOverlays } from './overlays'; +import { defaultScrollPhysics } from './physics'; +import { createWindowPool } from './pool'; +import { createScrollSelection } from './selection'; +import { touchLongPress, wireKeySelection, wireMouseSelection } from './selection-input'; +import type { ScrollController, ScrollControllerEvents, ScrollControllerOptions } from './types'; +import { + ratioOf, + relayout, + revealFocus, + schedule, + selectionRects, + shownRects, + type ScrollView, +} from './view'; +import { highlightAt, wireHighlightHover } from './highlight-hit'; + +/** A scrolled reader's view on `canvas` (see {@link ScrollController}). */ +export function createScrollController( + reader: Reader, + canvas: HTMLCanvasElement, + options: ScrollControllerOptions = {}, +): ScrollController { + const surface = createDisplaySurface(canvas); + const view: ScrollView = { + reader, + surface, + emitter: createEmitter(), + pool: createWindowPool(surface.gl, (spreadIndex, target) => + reader.renderSpreadTo(spreadIndex, target), + ), + overlays: createWindowOverlays(surface.gl), + motion: createScrollMotion(options.physics ?? defaultScrollPhysics), + colors: withHighlightColors(DEFAULT_HIGHLIGHT_COLORS, options.colors), + selection: undefined, + highlights: [], + highlighted: 0, + geometry: undefined, + current: reader.activeSpreadIndex, + rested: undefined, + frame: null, + }; + if (options.selection !== false) { + view.selection = createScrollSelection(reader, () => { + selectionChanged(view); + }); + } + const stops: (() => void)[] = []; + const mirrored = { done: false }; + const controller = controllerOf( + view, + () => { + for (const stop of stops) stop(); + }, + (container) => { + // The screen readers' mirror goes beside the view once it is shown. + if (!options.a11y?.enabled || mirrored.done) return; + mirrored.done = true; + stops.push(wireScrollA11y(view, options.a11y.container ?? container)); + }, + ); + stops.push(...wireView(view)); + if (options.input !== false) stops.push(...wireInput(view, controller)); + // The strip has its windows before it is shown: it can be moved at once. + relayout(view); + return controller; +} + +function wireView(view: ScrollView): (() => void)[] { + const { reader, surface } = view; + return [ + reader.onLayoutCommitted(() => { + relayout(view); + }), + reader.onSpreadContentInvalidated((spreadIndex) => { + view.pool.invalidate(spreadIndex); + schedule(view); + }), + surface.onPixelRatioChange(() => { + relayout(view); + }), + surface.onRestored(() => { + view.pool.dispose(); + view.overlays.dispose(); + schedule(view); + }), + ]; +} + +/** The built-in input: the strip's, and the mouse's selection when the view selects. */ +function wireInput(view: ScrollView, controller: ScrollController): (() => void)[] { + const canvas = view.surface.canvas; + const onTap = (x: number, y: number): void => { + tap(view, controller, x, y); + }; + const { selection } = view; + const hover = wireHighlightHover(view, controller, canvas); + if (selection === undefined) return [wireScrollInput(canvas, controller, onTap), hover]; + // A tap selects nothing: a touch's lets the selection go, as the mouse's does. + const touchTap = (x: number, y: number): void => { + selection.clear(); + onTap(x, y); + }; + return [ + wireScrollInput(canvas, controller, touchTap, { + mouseTaps: false, + longPress: touchLongPress(canvas, controller, selection), + }), + wireMouseSelection(canvas, controller, selection, onTap), + wireKeySelection(selection, () => { + revealFocus(view); + }), + hover, + ]; +} + +function controllerOf( + view: ScrollView, + unsubscribe: () => void, + mounted: (container: HTMLElement) => void, +): ScrollController { + const { motion } = view; + const ratio = (): number => ratioOf(view); + const controller: ScrollController = { + mount(container) { + mount(view, container, mounted); + }, + get canvas() { + return view.surface.canvas; + }, + get axis() { + return view.geometry?.axis; + }, + get offset() { + return motion.offset / ratio(); + }, + get extent() { + return (view.geometry?.extent ?? 0) / ratio(); + }, + get currentSpread() { + return view.current; + }, + get pageLength() { + return pageLength(view); + }, + ...movement(view), + scrollToLocator: (locator) => scrollToLocator(view, controller, locator), + ...hits(view), + get selection() { + return view.selection?.snapshot?.range ?? null; + }, + ...highlighting(view), + selectionHandles: () => selectionHandles(view), + beginSelectionHandleDrag: (edge, origin) => + beginSelectionHandleDrag(view, controller, edge, origin), + on: (event, listener) => view.emitter.on(event, listener), + dispose() { + dispose(view, unsubscribe); + }, + }; + return controller; +} + +/** What is under a point of the view. */ +function hits(view: ScrollView): Pick { + return { + pointAt: (x, y) => + view.geometry === undefined + ? undefined + : scrollPointAt(view.reader, view.geometry, view.motion.offset, view.surface.width, x, y), + targetAt: (point) => scrollTargetAt(view.reader, point), + }; +} + +function mount( + view: ScrollView, + container: HTMLElement, + mounted: (container: HTMLElement) => void, +): void { + // A host mounting it again where it is (a ref called on every render) + // neither lays the strip out again nor ends a selection. + if (view.surface.canvas.parentElement === container) return; + container.appendChild(view.surface.canvas); + mounted(container); + relayout(view); +} + +/** What is drawn over the text: the selection and the app's highlights. */ +function highlighting( + view: ScrollView, +): Pick { + return { + selectionRects: () => selectionRects(view), + viewRects: (rects) => shownRects(view, rects), + clearSelection() { + view.selection?.clear(); + }, + setHighlights(highlights) { + view.highlights = [...highlights]; + view.highlighted += 1; + schedule(view); + }, + }; +} + +/** What is selected changed: it is drawn again and the app hears it. */ +function selectionChanged(view: ScrollView): void { + schedule(view); + const snapshot = view.selection?.snapshot ?? null; + view.emitter.emit('selection', { + range: snapshot?.range ?? null, + text: snapshot?.text ?? '', + selecting: view.selection?.selecting ?? false, + rects: selectionRects(view), + handles: selectionHandles(view), + }); +} + +function dispose(view: ScrollView, unsubscribe: () => void): void { + if (view.frame !== null) cancelAnimationFrame(view.frame); + unsubscribe(); + view.selection?.dispose(); + view.pool.dispose(); + view.overlays.dispose(); + view.surface.dispose(); + view.emitter.dispose(); + view.surface.canvas.remove(); +} + +async function scrollToLocator( + view: ScrollView, + controller: ScrollController, + locator: ReaderLocator, +): Promise { + const located = await view.reader.navigateToLocator?.(locator); + if (located?.status !== 'resolved') return false; + controller.scrollToSpread(located.spreadIndex, located.blockOffset ?? 0); + return true; +} + +/** A tap of the built-in input: the point and what is under it. */ +function tap(view: ScrollView, controller: ScrollController, x: number, y: number): void { + const point = controller.pointAt(x, y); + if (point === undefined) return; + // Where the highlight is now, before the strip moves on. + const highlight = highlightAt(view, point); + controller.targetAt(point).then( + (target) => { + view.emitter.emit('tap', { point, target, highlight }); + }, + (error: unknown) => { + console.error('[rito] the scroll view could not read what a tap is on', error); + }, + ); +} diff --git a/packages/kit/src/scroll/geometry.ts b/packages/kit/src/scroll/geometry.ts new file mode 100644 index 000000000..88421d0fb --- /dev/null +++ b/packages/kit/src/scroll/geometry.ts @@ -0,0 +1,77 @@ +import type { ReaderScrollLayout } from '@ritojs/core'; + +/** One window of a scrolled book on the device grid, along the scroll axis. */ +export interface ScrollWindow { + readonly spreadIndex: number; + /** Device pixels from the book's block start to the window's. */ + readonly start: number; + /** Its device pixels along the axis. */ + readonly length: number; +} + +/** + * A scrolled book's windows end to end on the device grid. Within a chapter + * window `k` starts `D(k) = floor(k·L·ratio + 0.5)` device pixels in, the + * rule the engine paints its windows by, and the chapter ends at + * `floor(extent·ratio + 0.5)`; chapters follow one another end to end. + * Offsets run from the book's block start: down the screen, or leftwards + * from the right edge for a book whose pages progress right to left. + */ +export interface ScrollGeometry { + readonly axis: ReaderScrollLayout['axis']; + readonly ratio: number; + /** The whole book's device pixels along the axis. */ + readonly extent: number; + /** Every window, in reading order. */ + readonly windows: readonly ScrollWindow[]; + /** The window holding device offset `offset`; the nearest end one outside the book. */ + windowAt(offset: number): ScrollWindow | undefined; + /** The windows a viewport `length` device pixels long shows from `offset`. */ + visible(offset: number, length: number): readonly ScrollWindow[]; +} + +export function createScrollGeometry(layout: ReaderScrollLayout, ratio: number): ScrollGeometry { + const windows: ScrollWindow[] = []; + let chapterStart = 0; + for (const chapter of layout.chapters) { + const end = Math.floor(chapter.extent * ratio + 0.5); + const row = (k: number): number => + k >= chapter.spreadCount ? end : Math.floor(k * layout.windowLength * ratio + 0.5); + for (let k = 0; k < chapter.spreadCount; k += 1) { + windows.push({ + spreadIndex: chapter.firstSpread + k, + start: chapterStart + row(k), + length: row(k + 1) - row(k), + }); + } + chapterStart += end; + } + const windowAt = (offset: number): ScrollWindow | undefined => { + let low = 0; + let high = windows.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if ((windows[middle]?.start ?? 0) <= offset) low = middle; + else high = middle - 1; + } + return windows[low]; + }; + return { + axis: layout.axis, + ratio, + extent: chapterStart, + windows, + windowAt, + visible(offset, length) { + const first = windowAt(offset); + if (first === undefined) return []; + const shown: ScrollWindow[] = []; + for (let index = windows.indexOf(first); index < windows.length; index += 1) { + const window = windows[index]; + if (window === undefined || window.start >= offset + length) break; + if (window.start + window.length > offset) shown.push(window); + } + return shown; + }, + }; +} diff --git a/packages/kit/src/scroll/handles.ts b/packages/kit/src/scroll/handles.ts new file mode 100644 index 000000000..a80ef8589 --- /dev/null +++ b/packages/kit/src/scroll/handles.ts @@ -0,0 +1,85 @@ +import type { ReaderTextCaret } from '@ritojs/core'; +import type { + SelectionClientPoint, + SelectionHandleDrag, + SelectionHandleEdge, + SelectionHandleState, +} from '../controller/types'; +import { edgeSpeed } from './selection-input'; +import type { ScrollController } from './types'; +import { shownRects, type ScrollView } from './view'; + +/** + * The ends of what is selected where the view shows them, in CSS pixels + * from its top-left corner, and the end the reader is moving; null when + * nothing is selected. + */ +export function selectionHandles(view: ScrollView): SelectionHandleState | null { + const snapshot = view.selection?.snapshot; + if (!snapshot) return null; + const at = (caret: ReaderTextCaret) => + shownRects(view, [{ pageIndex: caret.pageIndex, ...caret.geometry }])[0] ?? null; + return { + start: at(snapshot.range.start), + end: at(snapshot.range.end), + focusEdge: snapshot.focusDirection === 'forward' ? 'end' : 'start', + }; +} + +/** + * Drags end `edge` of what is selected from client point `origin`: each + * client point moves it to the page point under it, the strip scrolling on + * while the point is held near either end of the view. Null when nothing + * is selected to drag. + */ +export function beginSelectionHandleDrag( + view: ScrollView, + controller: ScrollController, + edge: SelectionHandleEdge, + origin: SelectionClientPoint, +): SelectionHandleDrag | null { + const drag = view.selection?.handleDrag(edge); + if (!drag) return null; + const canvas = view.surface.canvas; + let at = local(canvas, origin); + let frame: number | null = null; + const pointOf = (point: { x: number; y: number }) => controller.pointAt(point.x, point.y); + const follow = (): void => { + frame = null; + const speed = edgeSpeed(canvas, controller, at); + if (speed === 0) return; + controller.scrollBy(speed); + const point = pointOf(at); + if (point) drag.update(point); + frame = requestAnimationFrame(follow); + }; + const stop = (): void => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + }; + return { + update(point) { + at = local(canvas, point); + const under = pointOf(at); + if (under) drag.update(under); + if (frame === null && edgeSpeed(canvas, controller, at) !== 0) { + frame = requestAnimationFrame(follow); + } + }, + finish(point) { + stop(); + const under = pointOf(local(canvas, point)); + if (under) drag.finish(under); + else drag.cancel(); + }, + cancel() { + stop(); + drag.cancel(); + }, + }; +} + +function local(canvas: HTMLCanvasElement, point: SelectionClientPoint): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + return { x: point.clientX - rect.left, y: point.clientY - rect.top }; +} diff --git a/packages/kit/src/scroll/highlight-hit.ts b/packages/kit/src/scroll/highlight-hit.ts new file mode 100644 index 000000000..0b96a95c8 --- /dev/null +++ b/packages/kit/src/scroll/highlight-hit.ts @@ -0,0 +1,102 @@ +import type { Rect } from '../interaction/layout-types'; +import type { ScrollPoint } from './hit'; +import type { ScrollController } from './types'; +import { shownRects, type ScrollView } from './view'; + +/** One of the app's highlights under a point of the view (see `setHighlights`). */ +export interface ScrollHighlightHit { + readonly id: string; + /** + * Its rects on the point's page, bounded, in screen coordinates (CSS + * pixels for `position: fixed`): where a tooltip or a popover for it goes. + */ + readonly bounds: Rect; +} + +/** + * The app's highlight under `point`: of those with a rect there, the one + * drawn on top (the highest `zIndex`, the later of equals), as a page + * view's annotations are hit before its links. + */ +export function highlightAt(view: ScrollView, point: ScrollPoint): ScrollHighlightHit | null { + let hit: ScrollView['highlights'][number] | undefined; + for (const highlight of view.highlights) { + if (!highlight.rects.some((rect) => rect.pageIndex === point.pageIndex && holds(rect, point))) { + continue; + } + if (hit === undefined || (highlight.zIndex ?? 0) >= (hit.zIndex ?? 0)) hit = highlight; + } + if (hit === undefined) return null; + const onPage = hit.rects.filter((rect) => rect.pageIndex === point.pageIndex); + const [shown] = shownRects(view, [{ pageIndex: point.pageIndex, ...bounding(onPage) }]); + if (shown === undefined) return null; + const canvas = view.surface.canvas.getBoundingClientRect(); + return { id: hit.id, bounds: { ...shown, x: shown.x + canvas.left, y: shown.y + canvas.top } }; +} + +/** + * The highlight under a mouse that rests on the view with no button + * pressed, told (`highlightHover`) each time it changes or the strip moves + * it under the mouse, and as gone when the mouse leaves or presses. + */ +export function wireHighlightHover( + view: ScrollView, + controller: ScrollController, + canvas: HTMLCanvasElement, +): () => void { + let hovered: ScrollHighlightHit | null = null; + // Where the mouse rests on the view, in its CSS pixels; null once it left or pressed. + let mouse: { x: number; y: number } | null = null; + const tell = (): void => { + const point = mouse === null ? undefined : controller.pointAt(mouse.x, mouse.y); + const hit = point === undefined ? null : highlightAt(view, point); + if (sameHit(hit, hovered)) return; + hovered = hit; + view.emitter.emit('highlightHover', { highlight: hit }); + }; + const onMove = (event: PointerEvent): void => { + if (event.pointerType !== 'mouse') return; + const rect = canvas.getBoundingClientRect(); + mouse = + event.buttons === 0 ? { x: event.clientX - rect.left, y: event.clientY - rect.top } : null; + tell(); + }; + const onLeave = (): void => { + mouse = null; + tell(); + }; + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerdown', onLeave); + canvas.addEventListener('pointerleave', onLeave); + const stopScroll = controller.on('scroll', () => { + if (mouse !== null || hovered !== null) tell(); + }); + return () => { + stopScroll(); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerdown', onLeave); + canvas.removeEventListener('pointerleave', onLeave); + }; +} + +function sameHit(a: ScrollHighlightHit | null, b: ScrollHighlightHit | null): boolean { + if (a === null || b === null) return a === b; + return a.id === b.id && a.bounds.x === b.bounds.x && a.bounds.y === b.bounds.y; +} + +function holds(rect: Rect, point: { readonly x: number; readonly y: number }): boolean { + return ( + point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height + ); +} + +function bounding(rects: readonly Rect[]): Rect { + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + return { x: left, y: top, width: right - left, height: bottom - top }; +} diff --git a/packages/kit/src/scroll/highlights.ts b/packages/kit/src/scroll/highlights.ts new file mode 100644 index 000000000..cc50f0a82 --- /dev/null +++ b/packages/kit/src/scroll/highlights.ts @@ -0,0 +1,93 @@ +import type { ReaderExactTextRangeRect } from '@ritojs/core'; +import type { Rect } from '../interaction/layout-types'; +import type { OverlayLayer } from '../painter/types'; +import { windowRect } from './composite'; +import type { ScrollGeometry, ScrollWindow } from './geometry'; + +/** A page's rect in its content box, in CSS pixels, as the reader's interactions give it. */ +export type ScrollPageRect = Pick< + ReaderExactTextRangeRect, + 'pageIndex' | 'x' | 'y' | 'width' | 'height' +>; + +/** + * What an app shows over a scrolled view's text, a search's matches or a + * book's annotations: rects of pages, drawn over each window that shows + * them and moved with it. + */ +export interface ScrollHighlight { + readonly id: string; + readonly rects: readonly ScrollPageRect[]; + /** A CSS colour. */ + readonly color: string; + /** Higher highlights are drawn over lower ones; the selection is drawn at 0. */ + readonly zIndex?: number | undefined; + /** How each rect is marked: filled (the default), or a line along its bottom edge. */ + readonly mark?: 'fill' | 'underline' | undefined; +} + +/** A page's content box in its window's display list: the page's margins in. */ +export interface ScrollPageOrigin { + readonly marginLeft: number; + readonly marginTop: number; +} + +/** + * The highlights drawn over the window showing page `pageIndex`, in CSS + * pixels of the window's display list. + */ +export function windowLayers( + pageIndex: number, + origin: ScrollPageOrigin, + highlights: readonly ScrollHighlight[], +): OverlayLayer[] { + return highlights.map((highlight) => ({ + id: highlight.id, + color: highlight.color, + zIndex: highlight.zIndex ?? 0, + mark: highlight.mark, + rects: highlight.rects + .filter((rect) => rect.pageIndex === pageIndex) + .map((rect) => ({ + x: rect.x + origin.marginLeft, + y: rect.y + origin.marginTop, + width: rect.width, + height: rect.height, + })), + })); +} + +/** + * Page rects where a view shows them, in CSS pixels from the view's + * top-left corner, with the strip at device offset `offset` on a view + * `width` × `height` device pixels; a rect of a page no window on screen + * shows is left out. + */ +export function viewRects( + geometry: ScrollGeometry, + pageOf: (window: ScrollWindow) => number | undefined, + origin: ScrollPageOrigin, + offset: number, + width: number, + height: number, + rects: readonly ScrollPageRect[], +): Rect[] { + const { ratio } = geometry; + const at = Math.round(offset); + const shown = geometry.visible(at, geometry.axis === 'down' ? height : width); + const placed: Rect[] = []; + for (const window of shown) { + const pageIndex = pageOf(window); + const corner = windowRect(geometry, window, at, width, height); + for (const rect of rects) { + if (rect.pageIndex !== pageIndex) continue; + placed.push({ + x: corner.x / ratio + origin.marginLeft + rect.x, + y: corner.y / ratio + origin.marginTop + rect.y, + width: rect.width, + height: rect.height, + }); + } + } + return placed; +} diff --git a/packages/kit/src/scroll/hit.ts b/packages/kit/src/scroll/hit.ts new file mode 100644 index 000000000..cabbf8856 --- /dev/null +++ b/packages/kit/src/scroll/hit.ts @@ -0,0 +1,72 @@ +import type { Reader, ReaderInteractionTarget } from '@ritojs/core'; +import type { ScrollGeometry } from './geometry'; + +/** A point of a scrolled view: its window, that window's page and the point in its content box (CSS pixels). */ +export interface ScrollPoint { + readonly spreadIndex: number; + readonly pageIndex: number; + readonly x: number; + readonly y: number; +} + +/** + * The point under view coordinates `x`, `y` (CSS pixels from the view's + * top-left corner) with the strip at device offset `offset` on a view + * `width` device pixels wide. + */ +export function scrollPointAt( + reader: Reader, + geometry: ScrollGeometry, + offset: number, + width: number, + x: number, + y: number, +): ScrollPoint | undefined { + const { ratio } = geometry; + // How far along the strip the point is, and across it, in device pixels. + const along = geometry.axis === 'down' ? offset + y * ratio : offset + width - x * ratio; + const window = geometry.windowAt(along); + if (window === undefined || along < window.start || along >= window.start + window.length) { + return undefined; + } + const pageIndex = reader.spreads[window.spreadIndex]?.leftPageIndex; + if (pageIndex === undefined) return undefined; + const into = (along - window.start) / ratio; + const margins = reader.getLayoutGeometry(); + // The point in the window's own display list, then in its content box. + const [windowX, windowY] = + geometry.axis === 'down' ? [x, into] : [window.length / ratio - into, y]; + return { + spreadIndex: window.spreadIndex, + pageIndex, + x: windowX - margins.marginLeft, + y: windowY - margins.marginTop, + }; +} + +/** + * The link, note or image at a point of a scrolled view, if any: the page's + * actionable targets hit in reverse paint order, as the page view hits + * them, a text run being no target of its own. + */ +export async function scrollTargetAt( + reader: Reader, + point: ScrollPoint, +): Promise { + const page = await reader.interactions?.getPageTargets(point.pageIndex); + const targets = page?.targets ?? []; + for (let index = targets.length - 1; index >= 0; index -= 1) { + const target = targets[index]; + if (!target || target.kind === 'text') continue; + const { bounds } = target; + if ( + point.x >= bounds.x && + point.x <= bounds.x + bounds.width && + point.y >= bounds.y && + point.y <= bounds.y + bounds.height + ) { + return target; + } + } + return undefined; +} diff --git a/packages/kit/src/scroll/input.ts b/packages/kit/src/scroll/input.ts new file mode 100644 index 000000000..57d1d99f9 --- /dev/null +++ b/packages/kit/src/scroll/input.ts @@ -0,0 +1,79 @@ +import { wireDrag } from './touch-input'; +import type { ScrollController } from './types'; + +/** CSS pixels a wheel's line moves. */ +export const LINE = 40; + +/** A tap at view coordinates (CSS pixels from the view's top-left corner). */ +export type ScrollTap = (x: number, y: number) => void; + +/** What a touch held still and selecting does as it moves and lifts, at view coordinates. */ +export interface TouchSelecting { + move(x: number, y: number): void; + end(x: number, y: number): void; +} + +/** Starts a selection at view point `x`, `y` for a touch held still; null when it cannot. */ +export type ScrollLongPress = (x: number, y: number) => TouchSelecting | null; + +/** + * The built-in input: the wheel and the keys move the strip, a touch or a + * pen drags it and flings it, a touch held still selects through + * `longPress`, and a click taps unless `mouseTaps` is off (the mouse's + * selection taps instead). Forward is down the screen, or leftwards for a + * book that scrolls leftwards. Returns the unsubscribe. + */ +export function wireScrollInput( + canvas: HTMLCanvasElement, + controller: ScrollController, + tap: ScrollTap, + options: { readonly mouseTaps?: boolean; readonly longPress?: ScrollLongPress } = {}, +): () => void { + const stopKeys = wireWheelAndKeys(canvas, controller); + const stopDrag = wireDrag(canvas, controller, tap, options.longPress); + const onClick = (event: MouseEvent): void => { + const rect = canvas.getBoundingClientRect(); + tap(event.clientX - rect.left, event.clientY - rect.top); + }; + const clicks = options.mouseTaps ?? true; + if (clicks) canvas.addEventListener('click', onClick); + return () => { + stopKeys(); + stopDrag(); + if (clicks) canvas.removeEventListener('click', onClick); + }; +} + +function wireWheelAndKeys(canvas: HTMLCanvasElement, controller: ScrollController): () => void { + const page = (): number => controller.pageLength; + const onWheel = (event: WheelEvent): void => { + event.preventDefault(); + const scale = event.deltaMode === 1 ? LINE : event.deltaMode === 2 ? page() : 1; + const forward = controller.axis === 'left' ? event.deltaY - event.deltaX : event.deltaY; + controller.scrollBy(forward * scale); + }; + const left = (): boolean => controller.axis === 'left'; + const keys: Record number> = { + ArrowDown: () => (left() ? 0 : LINE), + ArrowUp: () => (left() ? 0 : -LINE), + ArrowLeft: () => (left() ? LINE : 0), + ArrowRight: () => (left() ? -LINE : 0), + PageDown: page, + ' ': page, + PageUp: () => -page(), + Home: () => -controller.offset, + End: () => controller.extent, + }; + const onKey = (event: KeyboardEvent): void => { + const move = keys[event.key]?.(); + if (move === undefined || move === 0 || event.defaultPrevented) return; + event.preventDefault(); + controller.scrollBy(event.key === ' ' && event.shiftKey ? -move : move, { animate: true }); + }; + canvas.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('keydown', onKey); + return () => { + canvas.removeEventListener('wheel', onWheel); + window.removeEventListener('keydown', onKey); + }; +} diff --git a/packages/kit/src/scroll/motion.ts b/packages/kit/src/scroll/motion.ts new file mode 100644 index 000000000..18866ec6c --- /dev/null +++ b/packages/kit/src/scroll/motion.ts @@ -0,0 +1,73 @@ +import type { ScrollPhysics, ScrollSimulation } from './physics'; + +/** + * The strip's offset over time, in device pixels: still, dragged (its raw + * offset, before the physics resists it past an end), or following a + * simulation from the time it started. + */ +export interface ScrollMotion { + /** Where the strip is shown. */ + readonly offset: number; + /** Whether it moves on its own. */ + readonly moving: boolean; + /** Puts the strip at `offset`, still. */ + place(offset: number): void; + grab(): void; + follow(delta: number): void; + release(velocity: number, now: number): void; + /** Glides to `offset` from where the strip is. */ + glide(simulation: ScrollSimulation, now: number): void; + /** Advances what moves the strip to `now`; false once it rests. */ + step(now: number): boolean; + /** The book is now `max` long along a viewport `viewport` long. */ + bound(max: number, viewport: number): void; +} + +export function createScrollMotion(physics: ScrollPhysics): ScrollMotion { + let shown = 0; + let raw: number | undefined; + let simulation: { readonly run: ScrollSimulation; readonly from: number } | undefined; + let max = 0; + let viewport = 1; + return { + get offset() { + return shown; + }, + get moving() { + return simulation !== undefined; + }, + place(offset) { + simulation = undefined; + raw = undefined; + shown = Math.min(Math.max(offset, 0), max); + }, + grab() { + simulation = undefined; + raw = shown; + }, + follow(delta) { + raw = (raw ?? shown) + delta; + shown = physics.drag(raw, max, viewport); + }, + release(velocity, now) { + raw = undefined; + simulation = { run: physics.release(shown, velocity, max), from: now }; + }, + glide(run, now) { + raw = undefined; + simulation = { run, from: now }; + }, + step(now) { + if (simulation === undefined) return false; + const at = simulation.run((now - simulation.from) / 1000); + shown = at.offset; + if (at.done) simulation = undefined; + return simulation !== undefined; + }, + bound(nextMax, nextViewport) { + max = Math.max(0, nextMax); + viewport = Math.max(1, nextViewport); + if (simulation === undefined && raw === undefined) shown = Math.min(Math.max(shown, 0), max); + }, + }; +} diff --git a/packages/kit/src/scroll/movement.ts b/packages/kit/src/scroll/movement.ts new file mode 100644 index 000000000..b085a4603 --- /dev/null +++ b/packages/kit/src/scroll/movement.ts @@ -0,0 +1,45 @@ +import { LINE } from './input'; +import type { ScrollController } from './types'; +import { moveTo, ratioOf, readingLine, readOn, schedule, type ScrollView } from './view'; + +/** How the app moves the strip: to an offset or a window, or under a finger. */ +export function movement( + view: ScrollView, +): Pick< + ScrollController, + 'scrollTo' | 'scrollBy' | 'scrollToSpread' | 'grab' | 'follow' | 'release' +> { + const { motion } = view; + const ratio = (): number => ratioOf(view); + return { + scrollTo(offset, options) { + moveTo(view, offset * ratio(), options?.animate ?? false); + }, + scrollBy(delta, options) { + moveTo(view, motion.offset + delta * ratio(), options?.animate ?? false); + }, + scrollToSpread(spreadIndex, blockOffset = 0) { + const window = view.geometry?.windows.find((each) => each.spreadIndex === spreadIndex); + if (window === undefined) return; + moveTo(view, window.start + blockOffset * ratio() - readingLine(view), false); + }, + grab() { + motion.grab(); + }, + follow(delta) { + motion.follow(delta * ratio()); + readOn(view); + schedule(view); + }, + release(velocity) { + motion.release(velocity * ratio(), performance.now()); + schedule(view); + }, + }; +} + +/** A page's move: the view's length along the axis less two lines, in CSS pixels. */ +export function pageLength(view: ScrollView): number { + const canvas = view.surface.canvas; + return (view.geometry?.axis === 'left' ? canvas.clientWidth : canvas.clientHeight) - 2 * LINE; +} diff --git a/packages/kit/src/scroll/overlays.ts b/packages/kit/src/scroll/overlays.ts new file mode 100644 index 000000000..bcb3f6236 --- /dev/null +++ b/packages/kit/src/scroll/overlays.ts @@ -0,0 +1,81 @@ +import { paintOverlayInto } from '../painter/overlay-painter'; +import { uploadCanvas } from '../painter/gl/targets'; +import type { OverlayLayer } from '../painter/types'; + +interface Entry { + readonly canvas: OffscreenCanvas; + readonly texture: WebGLTexture; + /** The highlights' version it was painted for. */ + version: number; + /** Whether it shows anything. */ + shown: boolean; +} + +/** + * What a scrolled view draws over each window: its highlights, painted once + * into a texture the window's device size and moved with the window, so + * the strip scrolls without painting them again. + */ +export interface WindowOverlays { + /** + * The overlay of window `spreadIndex`, `width` × `height` device pixels, + * for highlights at `version`, painted from `layers` (CSS pixels of the + * window's display list) when they changed; undefined when it shows none. + */ + texture( + spreadIndex: number, + width: number, + height: number, + ratio: number, + version: number, + layers: () => readonly OverlayLayer[], + ): WebGLTexture | undefined; + /** Drops the overlays of every window but these. */ + keep(spreadIndexes: ReadonlySet): void; + dispose(): void; +} + +export function createWindowOverlays(gl: WebGL2RenderingContext): WindowOverlays { + const entries = new Map(); + const drop = (entry: Entry): void => { + gl.deleteTexture(entry.texture); + }; + return { + texture(spreadIndex, width, height, ratio, version, layers) { + let entry = entries.get(spreadIndex); + if (entry?.canvas.width !== width || entry.canvas.height !== height) { + if (entry) drop(entry); + entry = { + canvas: new OffscreenCanvas(width, height), + texture: gl.createTexture(), + version: -1, + shown: false, + }; + entries.set(spreadIndex, entry); + } + if (entry.version !== version) { + entry.version = version; + const drawn = layers(); + entry.shown = drawn.some((layer) => layer.rects.length > 0); + if (entry.shown) { + const context = entry.canvas.getContext('2d'); + if (!context) throw new Error('A 2D canvas for scrolled highlights is unavailable'); + paintOverlayInto(context, drawn, ratio); + uploadCanvas(gl, entry.texture, entry.canvas); + } + } + return entry.shown ? entry.texture : undefined; + }, + keep(spreadIndexes) { + for (const [spreadIndex, entry] of entries) { + if (spreadIndexes.has(spreadIndex)) continue; + drop(entry); + entries.delete(spreadIndex); + } + }, + dispose() { + for (const entry of entries.values()) drop(entry); + entries.clear(); + }, + }; +} diff --git a/packages/kit/src/scroll/physics.ts b/packages/kit/src/scroll/physics.ts new file mode 100644 index 000000000..8b96faa4b --- /dev/null +++ b/packages/kit/src/scroll/physics.ts @@ -0,0 +1,86 @@ +import { rubberBand } from '../driver/rubber-band'; +import { springSettle, type SpringConfig } from '../driver/spring'; + +/** + * A motion the strip follows on its own: where it is `seconds` after it + * began, how fast it moves, and whether it has come to rest. Offsets are + * device pixels from the book's block start; velocities are per second. + */ +export type ScrollSimulation = (seconds: number) => { + readonly offset: number; + readonly velocity: number; + readonly done: boolean; +}; + +/** + * How the strip moves under a finger and after it. The built-in physics is + * one; an app gives its own for another feel, or drives the offset itself. + */ +export interface ScrollPhysics { + /** + * Where a drag shows the strip: `raw` is the offset the finger alone + * would give, `max` the last offset, `viewport` the viewport's length. + * Inside the book the strip follows the finger; past an end it resists. + */ + drag(raw: number, max: number, viewport: number): number; + /** The motion a release at `velocity` from `offset` starts. */ + release(offset: number, velocity: number, max: number): ScrollSimulation; +} + +/** How fast a fling slows: its velocity falls by e every 1/FRICTION seconds. */ +const FRICTION = 2; +/** Below this speed (device pixels a second) a fling has stopped. */ +const REST_SPEED = 8; +const BOUNCE: SpringConfig = { stiffness: 170, damping: 26 }; + +/** + * A browser-like strip: it follows a drag, resists past an end like a + * rubber band, glides after a fling, slowing exponentially, and springs + * back to an end it was pulled or flung past. + */ +export const defaultScrollPhysics: ScrollPhysics = { + drag(raw, max, viewport) { + if (raw < 0) return rubberBand(raw, viewport); + if (raw > max) return max + rubberBand(raw - max, viewport); + return raw; + }, + release(offset, velocity, max) { + if (offset < 0 || offset > max) return springBack(offset, velocity, offset < 0 ? 0 : max); + return fling(offset, velocity, max); + }, +}; + +function springBack(offset: number, velocity: number, to: number): ScrollSimulation { + const spring = springSettle(BOUNCE, offset, velocity, to); + return (seconds) => { + const at = spring(seconds); + // At rest the strip is at the end, not the sub-pixel short of it the + // spring stops at. + return at.done + ? { offset: to, velocity: 0, done: true } + : { offset: at.x, velocity: at.velocity, done: false }; + }; +} + +/** A glide from `offset` at `velocity`, springing back from an end it reaches. */ +function fling(offset: number, velocity: number, max: number): ScrollSimulation { + const glide = (seconds: number) => { + const decay = Math.exp(-FRICTION * seconds); + return { + offset: offset + (velocity / FRICTION) * (1 - decay), + velocity: velocity * decay, + }; + }; + // When, if ever, the glide reaches the end it heads for. + const end = velocity < 0 ? 0 : max; + const travel = end - offset; + const reach = velocity === 0 ? 0 : (FRICTION * travel) / velocity; + const hits = velocity !== 0 && reach < 1 && reach > 0; + const hitAt = hits ? -Math.log(1 - reach) / FRICTION : Infinity; + const bounce = hits ? springBack(end, glide(hitAt).velocity, end) : undefined; + return (seconds) => { + if (bounce !== undefined && seconds >= hitAt) return bounce(seconds - hitAt); + const at = glide(seconds); + return { ...at, done: Math.abs(at.velocity) < REST_SPEED }; + }; +} diff --git a/packages/kit/src/scroll/pool.ts b/packages/kit/src/scroll/pool.ts new file mode 100644 index 000000000..71024e7de --- /dev/null +++ b/packages/kit/src/scroll/pool.ts @@ -0,0 +1,71 @@ +import type { ReaderGlTarget } from '@ritojs/core'; +import { + createPageTarget, + deletePageTarget, + sizePageTarget, + type GlPageTarget, +} from '../painter/gl/targets'; + +/** Paints a spread into a target; false while the spread is not loaded yet. */ +export type WindowPainter = (spreadIndex: number, target: ReaderGlTarget) => boolean; + +interface Entry { + readonly target: GlPageTarget; + painted: boolean; +} + +/** + * The pictures of the windows a scrolled view shows and is about to: one + * texture a window, at the window's device size, painted once and kept + * until the window is dropped or its content changes. + */ +export interface WindowPool { + /** The window's picture, `width` × `height` device pixels; undefined while it loads. */ + picture(spreadIndex: number, width: number, height: number): WebGLTexture | undefined; + /** Forgets what a window was painted with: its next picture is painted again. */ + invalidate(spreadIndex: number): void; + /** Forgets every picture's paint, keeping the textures. */ + invalidateAll(): void; + /** Drops the pictures of every window but these. */ + keep(spreadIndexes: ReadonlySet): void; + dispose(): void; +} + +export function createWindowPool(gl: WebGL2RenderingContext, paint: WindowPainter): WindowPool { + const entries = new Map(); + return { + picture(spreadIndex, width, height) { + let entry = entries.get(spreadIndex); + if (entry === undefined) { + entry = { target: createPageTarget(gl, width, height), painted: false }; + entries.set(spreadIndex, entry); + } else if (entry.target.width !== width || entry.target.height !== height) { + sizePageTarget(gl, entry.target, width, height); + entry.painted = false; + } + if (!entry.painted) { + const { framebuffer } = entry.target; + entry.painted = paint(spreadIndex, { gl, framebuffer, width, height }); + } + return entry.painted ? entry.target.texture : undefined; + }, + invalidate(spreadIndex) { + const entry = entries.get(spreadIndex); + if (entry) entry.painted = false; + }, + invalidateAll() { + for (const entry of entries.values()) entry.painted = false; + }, + keep(spreadIndexes) { + for (const [spreadIndex, entry] of entries) { + if (spreadIndexes.has(spreadIndex)) continue; + deletePageTarget(gl, entry.target); + entries.delete(spreadIndex); + } + }, + dispose() { + for (const entry of entries.values()) deletePageTarget(gl, entry.target); + entries.clear(); + }, + }; +} diff --git a/packages/kit/src/scroll/position.ts b/packages/kit/src/scroll/position.ts new file mode 100644 index 000000000..9d2677a4a --- /dev/null +++ b/packages/kit/src/scroll/position.ts @@ -0,0 +1,29 @@ +import type { Reader, ReaderLocator } from '@ritojs/core'; +import type { ScrollGeometry } from './geometry'; + +/** + * Where the reader reads with the strip at device offset `offset`: the + * line at the reading line of the window under it, else that window's own + * reading anchor. Only a downward strip's lines are found by their point + * yet; a leftward one reads its window's anchor. + */ +export async function readingPosition( + reader: Reader, + geometry: ScrollGeometry, + offset: number, + readingLine: number, +): Promise { + const window = geometry.windowAt(offset + readingLine); + if (window === undefined) return undefined; + const pageIndex = reader.spreads[window.spreadIndex]?.leftPageIndex; + if (pageIndex === undefined) return undefined; + if (geometry.axis === 'down') { + // The reading line in the window's content box, a pixel in from its start. + const into = (offset + readingLine - window.start) / geometry.ratio; + const point = { pageIndex, x: 1, y: into - reader.getLayoutGeometry().marginTop }; + const caret = await reader.interactions?.textSelection?.resolveCaret(point); + if (caret?.status === 'resolved') return caret.caret.sourceLocator; + } + const anchor = await reader.interactions?.getPageReadingAnchor?.(pageIndex); + return anchor?.status === 'resolved' ? anchor.locator : undefined; +} diff --git a/packages/kit/src/scroll/selection-input.ts b/packages/kit/src/scroll/selection-input.ts new file mode 100644 index 000000000..c82920710 --- /dev/null +++ b/packages/kit/src/scroll/selection-input.ts @@ -0,0 +1,256 @@ +import { + isAppleKeyboardPlatform, + keyboardSelectionMovement, +} from '../controller/wiring/keyboard-selection-map'; +import type { NativeSelectionGranularity } from '../interaction/selection/native-types'; +import type { ScrollLongPress, ScrollTap } from './input'; +import type { ScrollSelection } from './selection'; +import type { ScrollController } from './types'; + +/** CSS pixels a pressed mouse moves before it selects rather than clicks. */ +const DRAG_SLOP = 4; +/** CSS pixels from a view's ends where a drag scrolls the strip on. */ +const EDGE = 40; +/** CSS pixels a frame the strip scrolls with the pointer at the very end. */ +const EDGE_SPEED = 24; + +interface Press { + readonly x: number; + readonly y: number; + granularity: NativeSelectionGranularity; + /** Whether it selects: it moved past the slop or picked a word or paragraph. */ + selecting: boolean; + /** The pointer's last view point. */ + at: ViewPoint; + /** Whether the button is still held: a frame asked for before it let go does nothing. */ + held: boolean; + frame: number | null; +} + +type ViewPoint = { readonly x: number; readonly y: number }; + +/** + * The mouse selects text: a drag selects from where it was pressed, a + * double press a word and a triple press a paragraph, and a drag held + * near either end of the view scrolls the strip on under it. A press that + * does not move is a click: it clears what was selected and taps. Returns + * the unsubscribe. + */ +export function wireMouseSelection( + canvas: HTMLCanvasElement, + controller: ScrollController, + selection: ScrollSelection, + tap: ScrollTap, +): () => void { + let press: Press | undefined; + const onDown = (event: PointerEvent): void => { + if (event.pointerType !== 'mouse' || event.button !== 0) return; + canvas.setPointerCapture(event.pointerId); + press = pressed(viewPoint(canvas, event), 'character'); + }; + const onMouseDown = clickCount(() => press, controller, selection); + const onMove = (event: PointerEvent): void => { + if (press === undefined || event.pointerType !== 'mouse') return; + drag(canvas, controller, selection, press, viewPoint(canvas, event)); + }; + const onUp = (event: PointerEvent): void => { + if (press === undefined || event.pointerType !== 'mouse') return; + const released = press; + press = undefined; + const at = viewPoint(canvas, event); + release(released); + if (released.selecting) select(controller, selection, released, at, 'end'); + else if (event.type === 'pointerup') { + selection.clear(); + tap(at.x, at.y); + } + }; + const events = [ + ['pointerdown', onDown], + ['pointermove', onMove], + ['pointerup', onUp], + ['pointercancel', onUp], + ] as const; + for (const [name, handler] of events) canvas.addEventListener(name, handler); + canvas.addEventListener('mousedown', onMouseDown); + return () => { + if (press?.frame != null) cancelAnimationFrame(press.frame); + for (const [name, handler] of events) canvas.removeEventListener(name, handler); + canvas.removeEventListener('mousedown', onMouseDown); + }; +} + +/** + * The click count is the mouse event's that follows the pointer's: a + * second or third press selects a word or a paragraph at once. + */ +function clickCount( + current: () => Press | undefined, + controller: ScrollController, + selection: ScrollSelection, +): (event: MouseEvent) => void { + return (event) => { + const press = current(); + const granularity = granularityOf(event.detail); + if (press === undefined || event.button !== 0 || granularity === 'character') return; + press.granularity = granularity; + press.selecting = true; + select(controller, selection, press, press.at, 'begin'); + }; +} + +/** The button let go: no more scrolling at the edges for this press. */ +function release(press: Press): void { + press.held = false; + if (press.frame !== null) cancelAnimationFrame(press.frame); +} + +function viewPoint(canvas: HTMLCanvasElement, event: PointerEvent): ViewPoint { + const rect = canvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; +} + +function pressed(at: ViewPoint, granularity: NativeSelectionGranularity): Press { + return { + ...at, + granularity, + selecting: granularity !== 'character', + at, + held: true, + frame: null, + }; +} + +/** Selects through the page point under view point `at`, if any. */ +function select( + controller: ScrollController, + selection: ScrollSelection, + press: Press, + at: ViewPoint, + step: 'begin' | 'move' | 'end', +): void { + const point = controller.pointAt(at.x, at.y); + if (point === undefined) return; + if (step === 'begin') selection.begin(point, press.granularity); + else if (step === 'move') selection.move(point); + else selection.end(point); +} + +/** The pressed mouse moved to `at`: past the slop it selects, and near an end it scrolls. */ +function drag( + canvas: HTMLCanvasElement, + controller: ScrollController, + selection: ScrollSelection, + press: Press, + at: ViewPoint, +): void { + press.at = at; + if (!press.selecting) { + if (Math.hypot(at.x - press.x, at.y - press.y) < DRAG_SLOP) return; + press.selecting = true; + select(controller, selection, press, { x: press.x, y: press.y }, 'begin'); + } + select(controller, selection, press, at, 'move'); + scrollAtEdge(canvas, controller, press, (point) => { + select(controller, selection, press, point, 'move'); + }); +} + +function granularityOf(clicks: number): NativeSelectionGranularity { + if (clicks === 2) return 'word'; + if (clicks >= 3) return 'paragraph'; + return 'character'; +} + +/** + * While the pointer is held near an end of the view along the axis, the + * strip scrolls on each frame, faster nearer the end, and the selection + * follows to what comes under the pointer. + */ +function scrollAtEdge( + canvas: HTMLCanvasElement, + controller: ScrollController, + press: Press, + follow: (at: ViewPoint) => void, +): void { + if (press.frame !== null) return; + const step = (): void => { + press.frame = null; + if (!press.held) return; + const speed = edgeSpeed(canvas, controller, press.at); + if (speed === 0) return; + controller.scrollBy(speed); + follow(press.at); + press.frame = requestAnimationFrame(step); + }; + if (edgeSpeed(canvas, controller, press.at) !== 0) press.frame = requestAnimationFrame(step); +} + +/** How far a frame scrolls with the pointer at `at`: forward near the far end, back near the near one. */ +export function edgeSpeed( + canvas: Pick, + controller: Pick, + at: ViewPoint, +): number { + // Along the axis from the view's block start: its top, or its right edge. + const left = controller.axis === 'left'; + const length = left ? canvas.clientWidth : canvas.clientHeight; + const along = left ? canvas.clientWidth - at.x : at.y; + if (along < EDGE) return -EDGE_SPEED * (1 - Math.max(0, along) / EDGE); + if (along > length - EDGE) return EDGE_SPEED * (1 - Math.max(0, length - along) / EDGE); + return 0; +} + +/** + * A Shift chord moves what is selected's focus, the platform's way and + * with the arrow keys read by the focus's writing mode, ahead of the keys + * that scroll; `moved` hears each movement that landed. Returns the + * unsubscribe. + */ +export function wireKeySelection(selection: ScrollSelection, moved: () => void): () => void { + const apple = isAppleKeyboardPlatform(navigator); + const onKey = (event: KeyboardEvent): void => { + if (selection.snapshot === null || selection.selecting) return; + const movement = keyboardSelectionMovement(event, apple, selection.focusWritingMode); + if (movement === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + selection.extend(movement).then( + (landed) => { + if (landed) moved(); + }, + (error: unknown) => { + console.error('[rito] the scroll view could not move its selection', error); + }, + ); + }; + window.addEventListener('keydown', onKey, { capture: true }); + return () => { + window.removeEventListener('keydown', onKey, { capture: true }); + }; +} + +/** + * A touch held still selects the word under it, and as it moves on the + * selection follows it, scrolling the strip on near either end of the + * view, until it lifts. + */ +export function touchLongPress( + canvas: HTMLCanvasElement, + controller: ScrollController, + selection: ScrollSelection, +): ScrollLongPress { + return (x, y) => { + const press = pressed({ x, y }, 'word'); + select(controller, selection, press, press.at, 'begin'); + return { + move: (toX, toY) => { + drag(canvas, controller, selection, press, { x: toX, y: toY }); + }, + end: (atX, atY) => { + release(press); + select(controller, selection, press, { x: atX, y: atY }, 'end'); + }, + }; + }; +} diff --git a/packages/kit/src/scroll/selection.ts b/packages/kit/src/scroll/selection.ts new file mode 100644 index 000000000..814e12f74 --- /dev/null +++ b/packages/kit/src/scroll/selection.ts @@ -0,0 +1,194 @@ +import type { + Reader, + ReaderExactSourceRangeRequest, + ReaderTextPoint, + ReaderTextSelectionMovement, + ReaderWritingMode, +} from '@ritojs/core'; +import { createNativeSelectionEngine } from '../interaction/selection/native-engine'; +import type { + NativeSelectionCapability, + NativeSelectionEngine, + NativeSelectionGranularity, + NativeSelectionHandleDrag, + NativeSelectionHandleEdge, + NativeSelectionSnapshot, +} from '../interaction/selection/native-types'; + +/** + * Text selected in a scrolled view, through the reader's own text + * selection: points are page points of the windows (see `pointAt`), so a + * selection runs on from one window into the next. + */ +export interface ScrollSelection { + begin(point: ReaderTextPoint, granularity: NativeSelectionGranularity): void; + move(point: ReaderTextPoint): void; + end(point: ReaderTextPoint): void; + clear(): void; + /** + * Moves what is selected's focus by `movement`, as a Shift chord does, + * after the movements asked for before it; false when nothing selected + * can move. + */ + extend(movement: ReaderTextSelectionMovement): Promise; + /** Drags end `edge` of what is selected; null when nothing selected can be dragged. */ + handleDrag(edge: NativeSelectionHandleEdge): NativeSelectionHandleDrag | null; + /** What is selected, as a gesture selects it and once it has. */ + readonly snapshot: NativeSelectionSnapshot | null; + /** Whether a gesture is selecting. */ + readonly selecting: boolean; + /** The writing mode of the line the focus is on, which turns its arrow keys. */ + readonly focusWritingMode: ReaderWritingMode; + /** Counts every change of what is selected. */ + readonly version: number; + /** Before a layout is replaced: where the selection reads in the source. */ + carry(): ReaderExactSourceRangeRequest | null; + /** Shows a carried selection again in the layout just committed. */ + restore(carried: ReaderExactSourceRangeRequest | null): void; + dispose(): void; +} + +/** A scrolled view's selection; undefined for a reader that cannot select text. */ +export function createScrollSelection( + reader: Reader, + changed: () => void, +): ScrollSelection | undefined { + const capability = reader.interactions?.textSelection; + if (capability === undefined) return undefined; + const engine = createNativeSelectionEngine(capability, { onError: report }); + let version = 0; + let movements = Promise.resolve(true); + const book = holdBookWhileSelecting(reader, engine); + const stop = engine.onChange(() => { + version += 1; + book.settle(); + changed(); + }); + return { + ...gestures(engine), + extend(movement) { + movements = movements.then( + () => moved(engine, movement), + () => false, + ); + return movements; + }, + handleDrag: (edge) => engine.beginHandleDrag(edge), + get snapshot() { + return engine.getSnapshot(); + }, + get selecting() { + return engine.getState() === 'selecting'; + }, + get focusWritingMode() { + return engine.getSnapshot()?.range.focus.writingMode ?? 'horizontal-tb'; + }, + get version() { + return version; + }, + carry: () => carried(engine), + restore: restorer(engine, capability), + dispose() { + stop(); + book.release(); + engine.dispose(); + }, + }; +} + +/** + * A layout replacing the one a selection is being made in would end the + * gesture: the whole-book layout waits until the selection is made, and + * then carries it over like any relayout (as the page controller holds it). + */ +function holdBookWhileSelecting( + reader: Reader, + engine: NativeSelectionEngine, +): { settle(): void; release(): void } { + let held: (() => void) | undefined; + const release = (): void => { + const done = held; + held = undefined; + done?.(); + }; + return { + settle() { + const selecting = engine.getState() === 'selecting'; + if (selecting && held === undefined) held = reader.holdBookLayout?.() ?? (() => undefined); + else if (!selecting) release(); + }, + release, + }; +} + +/** A pointer's gesture selecting, and letting the selection go. */ +function gestures( + engine: NativeSelectionEngine, +): Pick { + return { + begin: (point, granularity) => { + engine.handlePointerDown(point, granularity); + }, + move: (point) => { + engine.handlePointerMove(point); + }, + end: (point) => { + engine.handlePointerUp(point); + }, + clear: () => { + engine.clear(); + }, + }; +} + +/** One movement of the focus, published once it resolved. */ +async function moved( + engine: NativeSelectionEngine, + movement: ReaderTextSelectionMovement, +): Promise { + const command = engine.beginKeyboardMovement(movement); + if (command === null) return false; + try { + const outcome = await command.result; + // A boundary is committed too: it keeps the line position the next + // vertical movement starts from. + if (outcome.status === 'cancelled') return false; + return command.commit() && outcome.status === 'resolved'; + } finally { + command.finish(); + } +} + +function report(error: unknown): void { + console.error('[rito] the scroll view could not select text', error); +} + +/** Where a selection made reads in the source, as another layout can find it. */ +function carried(engine: NativeSelectionEngine): ReaderExactSourceRangeRequest | null { + const locator = engine.getState() === 'selected' ? engine.getSnapshot()?.sourceLocator : null; + if (!locator?.sourceRange) return null; + return { href: locator.href, sourceRange: locator.sourceRange }; +} + +/** + * Shows a carried selection in each layout committed, as a browser keeps a + * selection through a resize: found anew in that layout, and shown unless + * a later layout replaced it or a gesture selected meanwhile. + */ +function restorer( + engine: NativeSelectionEngine, + capability: NativeSelectionCapability, +): (request: ReaderExactSourceRangeRequest | null) => void { + let layouts = 0; + return (request) => { + layouts += 1; + engine.invalidate(); + if (request === null) return; + const layout = layouts; + capability.resolveTextRangeFromSource?.(request).then((resolution) => { + if (layout === layouts && resolution?.status === 'resolved') { + engine.restore(resolution.range); + } + }, report); + }; +} diff --git a/packages/kit/src/scroll/touch-input.ts b/packages/kit/src/scroll/touch-input.ts new file mode 100644 index 000000000..9d8f4ca80 --- /dev/null +++ b/packages/kit/src/scroll/touch-input.ts @@ -0,0 +1,143 @@ +import { estimateVelocity, type VelocitySample } from '../driver/transition-velocity'; +import type { ScrollLongPress, ScrollTap, TouchSelecting } from './input'; +import type { ScrollController } from './types'; + +/** A touch that moves less than this and lifts within TAP_MS is a tap. */ +const TAP_SLOP = 8; +const TAP_MS = 400; +/** How long a touch is held still before it selects rather than drags. */ +const LONG_PRESS_MS = 350; + +interface Touching { + readonly x: number; + readonly y: number; + readonly at: number; + last: { x: number; y: number }; + samples: VelocitySample[]; + /** Whether it moved past the tap slop: it drags, and selects no more. */ + moved: boolean; + timer: ReturnType | undefined; + selecting: TouchSelecting | null; +} + +interface TouchInput { + readonly canvas: HTMLCanvasElement; + readonly controller: ScrollController; + readonly tap: ScrollTap; + readonly longPress: ScrollLongPress | undefined; + touch: Touching | undefined; +} + +/** + * A touch or a pen drags the strip and flings it, a short still touch + * taps, and one held still selects through `longPress`. Returns the + * unsubscribe. + */ +export function wireDrag( + canvas: HTMLCanvasElement, + controller: ScrollController, + tap: ScrollTap, + longPress: ScrollLongPress | undefined, +): () => void { + const input: TouchInput = { canvas, controller, tap, longPress, touch: undefined }; + const events = [ + [ + 'pointerdown', + (event: PointerEvent) => { + touchDown(input, event); + }, + ], + [ + 'pointermove', + (event: PointerEvent) => { + touchMove(input, event); + }, + ], + [ + 'pointerup', + (event: PointerEvent) => { + touchUp(input, event); + }, + ], + [ + 'pointercancel', + (event: PointerEvent) => { + touchUp(input, event); + }, + ], + ] as const; + for (const [name, handler] of events) canvas.addEventListener(name, handler); + return () => { + clearTimeout(input.touch?.timer); + for (const [name, handler] of events) canvas.removeEventListener(name, handler); + }; +} + +function local(input: TouchInput, event: PointerEvent): { x: number; y: number } { + const rect = input.canvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; +} + +function touchDown(input: TouchInput, event: PointerEvent): void { + if (event.pointerType === 'mouse' || !event.isPrimary) return; + input.canvas.setPointerCapture(event.pointerId); + input.controller.grab(); + const at = { x: event.clientX, y: event.clientY }; + const held: Touching = { + ...at, + at: event.timeStamp, + last: at, + samples: [{ dx: 0, timestamp: event.timeStamp }], + moved: false, + timer: undefined, + selecting: null, + }; + input.touch = held; + const { longPress } = input; + if (longPress === undefined) return; + const point = local(input, event); + held.timer = setTimeout(() => { + held.timer = undefined; + if (input.touch !== held || held.moved) return; + input.controller.release(0); + held.selecting = longPress(point.x, point.y); + }, LONG_PRESS_MS); +} + +function touchMove(input: TouchInput, event: PointerEvent): void { + const { touch, controller } = input; + if (touch === undefined || !event.isPrimary) return; + if (touch.selecting) { + const at = local(input, event); + touch.selecting.move(at.x, at.y); + return; + } + if (Math.hypot(event.clientX - touch.x, event.clientY - touch.y) >= TAP_SLOP) { + touch.moved = true; + clearTimeout(touch.timer); + } + const delta = + controller.axis === 'left' ? event.clientX - touch.last.x : touch.last.y - event.clientY; + touch.last = { x: event.clientX, y: event.clientY }; + const travelled = (touch.samples.at(-1)?.dx ?? 0) + delta; + touch.samples = [...touch.samples, { dx: travelled, timestamp: event.timeStamp }].slice(-5); + controller.follow(delta); +} + +function touchUp(input: TouchInput, event: PointerEvent): void { + const ended = input.touch; + if (ended === undefined || !event.isPrimary) return; + input.touch = undefined; + clearTimeout(ended.timer); + if (ended.selecting) { + const at = local(input, event); + ended.selecting.end(at.x, at.y); + return; + } + const still = !ended.moved && event.timeStamp - ended.at < TAP_MS; + input.controller.release(still ? 0 : estimateVelocity(ended.samples) * 1000); + if (still && event.type === 'pointerup') { + const at = local(input, event); + input.tap(at.x, at.y); + } +} diff --git a/packages/kit/src/scroll/types.ts b/packages/kit/src/scroll/types.ts new file mode 100644 index 000000000..177d7b7d6 --- /dev/null +++ b/packages/kit/src/scroll/types.ts @@ -0,0 +1,170 @@ +import type { ReaderInteractionTarget, ReaderLocator, ReaderTextRange } from '@ritojs/core'; +import type { HighlightColors } from '../controller/overlay/colors'; +import type { + SelectionClientPoint, + SelectionHandleDrag, + SelectionHandleEdge, + SelectionHandleState, +} from '../controller/types'; +import type { Rect } from '../interaction/layout-types'; +import type { ScrollHighlightHit } from './highlight-hit'; +import type { ScrollHighlight, ScrollPageRect } from './highlights'; +import type { ScrollPoint } from './hit'; +import type { ScrollPhysics } from './physics'; + +export interface ScrollControllerOptions { + /** How the strip moves under a finger and after it: the built-in browser-like physics by default. */ + readonly physics?: ScrollPhysics | undefined; + /** + * Whether the built-in wheel, touch and key input drives the strip + * (default true). An app that drives it itself, through `grab`, `follow`, + * `release`, `scrollTo` and `scrollBy`, turns it off. + */ + readonly input?: boolean | undefined; + /** + * Whether the view selects text (default true): the built-in input's + * mouse drag selects, and what is selected is drawn over the windows. + */ + readonly selection?: boolean | undefined; + /** The colours the selection is drawn in, the kit's own by default. */ + readonly colors?: Partial | undefined; + /** + * A hidden mirror of the text on screen for screen readers, put in + * `container` (beside the view by default) once the strip rests; a link + * activated in it taps its target. + */ + readonly a11y?: { readonly enabled?: boolean; readonly container?: HTMLElement } | undefined; +} + +/** Offsets are CSS pixels along the scroll axis from the book's block start. */ +export interface ScrollControllerEvents { + /** + * The strip moved: where it is, the book's length and the view's along + * the axis, and how far through the book it is, from 0 at the start to 1 + * with the book's end at the view's. + */ + scroll: { + readonly offset: number; + readonly extent: number; + readonly viewport: number; + readonly progress: number; + }; + /** The window under the reading line changed: the reader moved on to it. */ + window: { readonly spreadIndex: number }; + /** The strip came to rest. */ + rest: { readonly offset: number }; + /** Where the reader reads once the strip rests: the line at the reading line. */ + position: { readonly locator: ReaderLocator }; + /** + * The built-in input's tap: the point, the link, note or image under it, + * and the app's highlight there (see `setHighlights`), which a page view + * hits before its links. + */ + tap: { + readonly point: ScrollPoint; + readonly target: ReaderInteractionTarget | undefined; + readonly highlight: ScrollHighlightHit | null; + }; + /** + * The app's highlight under a mouse moving over the view with no button + * pressed changed: the one now there, or null for none (or the mouse gone). + */ + highlightHover: { readonly highlight: ScrollHighlightHit | null }; + /** + * What is selected changed, as a gesture selects it and once it has: + * the range and its text, and its rects where the view shows them (CSS + * pixels from the view's top-left corner; see `selectionRects` once the + * strip moves). + */ + selection: { + readonly range: ReaderTextRange | null; + readonly text: string; + readonly selecting: boolean; + readonly rects: readonly Rect[]; + /** Its ends where the view shows them (see `selectionHandles`). */ + readonly handles: SelectionHandleState | null; + }; +} + +/** + * A scrolled reader's view: the book's windows end to end, down the screen + * or leftwards, moved by its own physics on one canvas. It keeps each + * window's picture while it is near and tells the reader which window is + * under the reading line; how the strip moves is the app's to replace. + */ +export interface ScrollController { + /** Puts the canvas in `container`; mounting it where it already is changes nothing. */ + mount(container: HTMLElement): void; + /** The canvas the view draws on. */ + readonly canvas: HTMLCanvasElement; + /** The book's axis; undefined until a scrolled layout is committed. */ + readonly axis: 'down' | 'left' | undefined; + /** Where the strip is, in CSS pixels from the book's block start. */ + readonly offset: number; + /** The whole book's length along the axis, in CSS pixels. */ + readonly extent: number; + /** The window under the reading line. */ + readonly currentSpread: number; + /** Moves the strip to `offset`, gliding there when `animate`. */ + scrollTo(offset: number, options?: { readonly animate?: boolean }): void; + scrollBy(delta: number, options?: { readonly animate?: boolean }): void; + /** + * How far a page moves the strip, as PageDown and PageUp move it: the + * view's length along the axis less two lines, in CSS pixels. + */ + readonly pageLength: number; + /** Shows window `spreadIndex` from `blockOffset` into it at the reading line. */ + scrollToSpread(spreadIndex: number, blockOffset?: number): void; + /** + * Puts the line `locator` lands on at the reading line, its window's top + * when it names no line; false when the locator lands nowhere. + */ + scrollToLocator(locator: ReaderLocator): Promise; + /** The point under view coordinates `x`, `y` (CSS pixels from the view's top-left corner). */ + pointAt(x: number, y: number): ScrollPoint | undefined; + /** The link, note or image at a point, if any. */ + targetAt(point: ScrollPoint): Promise; + /** What is selected, if anything. */ + readonly selection: ReaderTextRange | null; + /** The selection's rects where the view shows them now, in CSS pixels from its top-left corner. */ + selectionRects(): readonly Rect[]; + /** + * Page rects where the view shows them now, in CSS pixels from its + * top-left corner (a target's bounds, a note's lines); a rect of a page + * no window on screen shows is left out. + */ + viewRects(rects: readonly ScrollPageRect[]): readonly Rect[]; + clearSelection(): void; + /** + * The ends of what is selected where the view shows them now, in CSS + * pixels from its top-left corner, and the end being moved; the shape the + * page controller gives its handles. + */ + selectionHandles(): SelectionHandleState | null; + /** + * Drags end `edge` of what is selected from client point `origin` (a + * handle grabbed), as the page controller's handles are dragged; null when + * nothing is selected. + */ + beginSelectionHandleDrag( + edge: SelectionHandleEdge, + origin: SelectionClientPoint, + ): SelectionHandleDrag | null; + /** + * What the app highlights over the text, in place of what it highlighted + * before: a search's matches, a book's annotations. Each is drawn over + * the windows that show its pages, above the selection. + */ + setHighlights(highlights: readonly ScrollHighlight[]): void; + /** A finger takes the strip: what it was gliding through stops. */ + grab(): void; + /** The finger moved the strip on by `delta`. */ + follow(delta: number): void; + /** The finger let go at `velocity` (CSS pixels a second, forward positive). */ + release(velocity: number): void; + on( + event: K, + listener: (data: ScrollControllerEvents[K]) => void, + ): () => void; + dispose(): void; +} diff --git a/packages/kit/src/scroll/view.ts b/packages/kit/src/scroll/view.ts new file mode 100644 index 000000000..c65ae3f8e --- /dev/null +++ b/packages/kit/src/scroll/view.ts @@ -0,0 +1,290 @@ +import type { Reader } from '@ritojs/core'; +import type { HighlightColors } from '../controller/overlay/colors'; +import { springSettle } from '../driver/spring'; +import type { Rect } from '../interaction/layout-types'; +import type { DisplaySurface } from '../painter/display-surface'; +import type { DisposableEmitter } from '../utils/event-emitter'; +import { compositeWindows } from './composite'; +import { createScrollGeometry, type ScrollGeometry, type ScrollWindow } from './geometry'; +import { viewRects, windowLayers, type ScrollHighlight, type ScrollPageRect } from './highlights'; +import type { ScrollMotion } from './motion'; +import type { WindowOverlays } from './overlays'; +import type { WindowPool } from './pool'; +import { readingPosition } from './position'; +import type { ScrollSelection } from './selection'; +import type { ScrollControllerEvents } from './types'; + +const GLIDE = { stiffness: 220, damping: 2 * Math.sqrt(220) }; + +/** A scrolled view's state, which its frames draw from. */ +export interface ScrollView { + readonly reader: Reader; + readonly surface: DisplaySurface; + readonly emitter: DisposableEmitter; + readonly pool: WindowPool; + readonly overlays: WindowOverlays; + readonly motion: ScrollMotion; + readonly colors: HighlightColors; + selection: ScrollSelection | undefined; + /** What the app highlights, and a count of its changes. */ + highlights: readonly ScrollHighlight[]; + highlighted: number; + geometry: ScrollGeometry | undefined; + /** The window under the reading line, as the reader was last told. */ + current: number; + /** Where the strip last came to rest, over which windows: a frame drawn there again is no new rest. */ + rested: { readonly offset: number; readonly geometry: ScrollGeometry } | undefined; + frame: number | null; +} + +/** Device pixels a CSS pixel of the windows shown: the ratio their geometry was made at. */ +export function ratioOf(view: ScrollView): number { + return view.geometry?.ratio ?? view.reader.dpr; +} + +/** The viewport's device pixels along the axis. */ +export function along(view: ScrollView): number { + return view.geometry?.axis === 'left' ? view.surface.width : view.surface.height; +} + +/** The reading line in device pixels: the content box's block start, where a locator's line is shown. */ +export function readingLine(view: ScrollView): number { + const margins = view.reader.getLayoutGeometry(); + const margin = view.geometry?.axis === 'left' ? margins.marginRight : margins.marginTop; + return margin * ratioOf(view); +} + +/** Moves the strip to device offset `target`, gliding there when `animate`. */ +export function moveTo(view: ScrollView, target: number, animate: boolean): void { + if (!animate) { + view.motion.place(target); + readOn(view); + } else { + const spring = springSettle(GLIDE, view.motion.offset, 0, target); + view.motion.glide((seconds) => { + const at = spring(seconds); + return { offset: at.done ? target : at.x, velocity: at.velocity, done: at.done }; + }, performance.now()); + } + schedule(view); +} + +export function schedule(view: ScrollView): void { + view.frame ??= requestAnimationFrame((now) => { + view.frame = null; + const moving = view.motion.step(now); + draw(view); + if (moving) schedule(view); + else rest(view); + }); +} + +/** The selection and the app's highlights, the selection drawn under them. */ +function highlightsOf(view: ScrollView): readonly ScrollHighlight[] { + const snapshot = view.selection?.snapshot; + if (!snapshot) return view.highlights; + return [ + { id: 'selection', rects: snapshot.rects, color: view.colors.selection, zIndex: 0 }, + ...view.highlights, + ]; +} + +function pageOf(view: ScrollView, window: ScrollWindow): number | undefined { + return view.reader.spreads[window.spreadIndex]?.leftPageIndex; +} + +/** Page rects where the view shows them, in CSS pixels from its top-left corner. */ +export function shownRects(view: ScrollView, rects: readonly ScrollPageRect[]): Rect[] { + const { geometry, surface } = view; + if (geometry === undefined) return []; + return viewRects( + geometry, + (window) => pageOf(view, window), + view.reader.getLayoutGeometry(), + view.motion.offset, + surface.width, + surface.height, + rects, + ); +} + +/** The selection's rects where the view shows them, in CSS pixels from its top-left corner. */ +export function selectionRects(view: ScrollView): Rect[] { + const snapshot = view.selection?.snapshot; + return snapshot ? shownRects(view, snapshot.rects) : []; +} + +/** CSS pixels a moved focus is kept in from the view's ends. */ +const FOCUS_MARGIN = 40; + +/** + * Scrolls the strip so the selection's focus is on screen, as a browser + * keeps a moved caret in view: by as little as brings it in from either + * end, or, when it moved to a window off screen, to its line at the + * reading line. + */ +export function revealFocus(view: ScrollView): void { + const { geometry, surface, motion } = view; + const focus = view.selection?.snapshot?.focusCaret; + if (geometry === undefined || !focus) return; + const { ratio } = geometry; + const origin = view.reader.getLayoutGeometry(); + const caret = { pageIndex: focus.pageIndex, ...focus.geometry }; + const down = geometry.axis === 'down'; + const rect = viewRects( + geometry, + (window) => pageOf(view, window), + origin, + motion.offset, + surface.width, + surface.height, + [caret], + )[0]; + if (rect === undefined) { + const window = geometry.windows.find((each) => pageOf(view, each) === focus.pageIndex); + if (window === undefined) return; + const blockOffset = down + ? origin.marginTop + caret.y + : window.length / ratio - (origin.marginLeft + caret.x + caret.width); + moveTo(view, window.start + blockOffset * ratio - readingLine(view), false); + return; + } + const length = along(view) / ratio; + const viewWidth = surface.width / ratio; + const [from, to] = down + ? [rect.y, rect.y + rect.height] + : [viewWidth - rect.x - rect.width, viewWidth - rect.x]; + if (from < FOCUS_MARGIN) moveTo(view, motion.offset + (from - FOCUS_MARGIN) * ratio, false); + else if (to > length - FOCUS_MARGIN) { + moveTo(view, motion.offset + (to - length + FOCUS_MARGIN) * ratio, false); + } +} + +function draw(view: ScrollView): void { + const { geometry, surface, motion, reader } = view; + if (geometry === undefined) return; + surface.clear(); + const version = view.highlighted + (view.selection?.version ?? 0); + const origin = reader.getLayoutGeometry(); + compositeWindows( + surface.compositor, + geometry, + motion.offset, + surface.width, + surface.height, + (window, rect) => view.pool.picture(window.spreadIndex, rect.width, rect.height), + (window, rect) => { + const pageIndex = pageOf(view, window); + if (pageIndex === undefined) return undefined; + return view.overlays.texture( + window.spreadIndex, + rect.width, + rect.height, + geometry.ratio, + version, + () => windowLayers(pageIndex, origin, highlightsOf(view)), + ); + }, + ); + keepNear(view, geometry); + const viewport = along(view); + view.emitter.emit('scroll', { + offset: motion.offset / geometry.ratio, + extent: geometry.extent / geometry.ratio, + viewport: viewport / geometry.ratio, + progress: Math.min(1, Math.max(0, motion.offset / Math.max(1, geometry.extent - viewport))), + }); + readOn(view); +} + +/** + * Tells the reader the window under the reading line once the strip is + * there: a layout committed before the next frame keeps that window. + */ +export function readOn(view: ScrollView): void { + const reading = view.geometry?.windowAt(view.motion.offset + readingLine(view)); + if (reading === undefined || reading.spreadIndex === view.current) return; + view.current = reading.spreadIndex; + view.reader.notifyActiveSpread(reading.spreadIndex); + view.emitter.emit('window', { spreadIndex: reading.spreadIndex }); +} + +/** + * The strip came to rest: say where, then where the reader reads — once + * for each place and layout, not again for a frame drawn there for a + * highlight or a window that landed. + */ +function rest(view: ScrollView): void { + const { geometry, motion } = view; + const offset = motion.offset; + if (view.rested?.offset === offset && view.rested.geometry === geometry) return; + view.rested = geometry === undefined ? undefined : { offset, geometry }; + view.emitter.emit('rest', { offset: offset / ratioOf(view) }); + if (geometry === undefined) return; + readingPosition(view.reader, geometry, offset, readingLine(view)).then( + (locator) => { + // A position the strip has since moved away from is not where it reads. + if (locator !== undefined && view.geometry === geometry && view.motion.offset === offset) { + view.emitter.emit('position', { locator }); + } + }, + (error: unknown) => { + console.error('[rito] the scroll view could not read its reading position', error); + }, + ); +} + +/** + * The windows shown and one either side keep their pictures; the ones + * beside are painted ahead, which has the reader load them. + */ +function keepNear(view: ScrollView, geometry: ScrollGeometry): void { + const shown = geometry.visible(Math.round(view.motion.offset), along(view)); + const first = shown[0]; + const last = shown.at(-1); + if (first === undefined || last === undefined) return; + const from = geometry.windows.indexOf(first); + const to = geometry.windows.indexOf(last); + const near = geometry.windows.slice(Math.max(0, from - 1), to + 2); + const { width, height } = view.surface; + for (const window of near) { + if (shown.includes(window)) continue; + const [w, h] = geometry.axis === 'down' ? [width, window.length] : [window.length, height]; + view.pool.picture(window.spreadIndex, w, h); + } + const kept = new Set(near.map((window) => window.spreadIndex)); + view.pool.keep(kept); + view.overlays.keep(kept); +} + +/** + * Takes the committed layout: the canvas at the viewport's size and the + * windows anew, the strip kept on the window the reader reads and as far + * into it as the reading line was, and the selection found again in it. + * That window is the reader's active spread, which it numbers in the new + * layout: a chapter laid out alone counts its own windows, the whole book + * counts them all, and another size cuts them anew. + */ +export function relayout(view: ScrollView): void { + const { reader, surface, motion } = view; + const line = readingLine(view); + const kept = view.geometry?.windowAt(motion.offset + line); + // How far into its window the reading line is, in CSS pixels. + const into = kept === undefined ? 0 : (motion.offset + line - kept.start) / ratioOf(view); + view.selection?.restore(view.selection.carry()); + const size = reader.getCanvasSize(1); + surface.setSize(size.width, size.height, reader.dpr); + const layout = reader.getScrollLayout(); + view.geometry = layout === undefined ? undefined : createScrollGeometry(layout, reader.dpr); + view.pool.invalidateAll(); + view.highlighted += 1; + if (view.geometry === undefined) return; + motion.bound(view.geometry.extent - along(view), along(view)); + const spreadIndex = reader.activeSpreadIndex; + const window = view.geometry.windows.find((each) => each.spreadIndex === spreadIndex); + if (window !== undefined) { + const depth = Math.min(into * view.geometry.ratio, window.length - 1); + motion.place(window.start + depth - readingLine(view)); + } + schedule(view); +} diff --git a/packages/kit/tests/chapter-scope-layout.test.ts b/packages/kit/tests/chapter-scope-layout.test.ts index 08c25dbde..13d123912 100644 --- a/packages/kit/tests/chapter-scope-layout.test.ts +++ b/packages/kit/tests/chapter-scope-layout.test.ts @@ -368,6 +368,7 @@ describe('what a host is told while a chapter is laid out alone', () => { }, }, hasRestored: () => true, + isSuspended: () => false, positionPersistence: { save }, } as never, createDisposableCollection(), diff --git a/packages/kit/tests/controller-navigation-surface.test.ts b/packages/kit/tests/controller-navigation-surface.test.ts index b364a31f9..2cbbe724d 100644 --- a/packages/kit/tests/controller-navigation-surface.test.ts +++ b/packages/kit/tests/controller-navigation-surface.test.ts @@ -28,6 +28,7 @@ describe('controller navigation surface', () => { createInteractionModeManager('gesture'), nav, {} as HTMLCanvasElement, + () => true, ); expect(Object.keys(controller)).toEqual( @@ -105,6 +106,7 @@ function createInternalsStub( }, coordState: { annotationStore: null, searchResults: { stale: false, refresh: null } }, restoreCompleted: false, + suspended: false, } as unknown as Internals; } diff --git a/packages/kit/tests/controller-suspension.test.ts b/packages/kit/tests/controller-suspension.test.ts new file mode 100644 index 000000000..6e41dfd91 --- /dev/null +++ b/packages/kit/tests/controller-suspension.test.ts @@ -0,0 +1,259 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Reader } from '@ritojs/core'; +import { createController } from '../src/controller'; +import { acceptsKeyboardManagerInput } from '../src/keyboard/input-state'; +import { readerInteractions } from './helpers/native-selection'; +import { createFakeGl } from './helpers/fake-gl'; + +beforeEach(() => { + vi.stubGlobal( + 'OffscreenCanvas', + class OffscreenCanvas { + width: number; + height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + getContext() { + return { clearRect: vi.fn(), drawImage: vi.fn() }; + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('a suspended controller', () => { + it('answers none of the layouts another view asks for, and takes the last one up when it resumes', () => { + const frames = stubFrames(); + const fixture = createReaderFixture(3, 1); + const controller = createController(fixture.reader, createCanvas()); + const container = document.createElement('div'); + document.body.append(container); + controller.mount(container); + const layouts = vi.fn(); + controller.on('layoutChange', layouts); + fixture.notifyActiveSpread.mockClear(); + + controller.suspend(); + expect(controller.suspended).toBe(true); + // Scrolled: eight windows, the fifth read; then paged again on spread 2. + fixture.commit(8, 4); + controller.nextSpread(); + controller.goToSpread(6); + fixture.commit(3, 2); + frames.runAll(); + + expect(layouts).not.toHaveBeenCalled(); + expect(fixture.notifyActiveSpread).not.toHaveBeenCalled(); + expect(fixture.renderSpreadTo).not.toHaveBeenCalled(); + + controller.resume(); + frames.runAll(); + + expect(controller.suspended).toBe(false); + expect(controller.currentSpread).toBe(2); + expect(layouts).toHaveBeenCalledOnce(); + expect(fixture.notifyActiveSpread).toHaveBeenCalledWith(2); + expect(fixture.renderSpreadTo.mock.calls[0]?.[0]).toBe(2); + controller.dispose(); + }); + + it('takes its canvas off the page and lets its buffer go, and puts it back where it was mounted last', () => { + stubFrames(); + const fixture = createReaderFixture(3, 0); + const canvas = createCanvas(); + const controller = createController(fixture.reader, canvas); + const first = document.createElement('div'); + const second = document.createElement('div'); + document.body.append(first, second); + controller.mount(first); + + controller.suspend(); + expect(canvas.isConnected).toBe(false); + expect(canvas.width).toBe(1); + expect(canvas.height).toBe(1); + + // Mounted meanwhile: that is where it goes back, and not before. + controller.mount(second); + expect(canvas.isConnected).toBe(false); + + controller.resume(); + expect(canvas.parentElement).toBe(second); + expect(canvas.width).toBeGreaterThanOrEqual(300); + expect(canvas.height).toBeGreaterThanOrEqual(400); + controller.dispose(); + }); + + it('takes no keys meanwhile and leaves the host enabling them as it was', () => { + stubFrames(); + const controller = createController(createReaderFixture(3, 0).reader, createCanvas()); + + controller.suspend(); + expect(acceptsKeyboardManagerInput(controller.keyboard)).toBe(false); + controller.keyboard.setEnabled(true); + expect(acceptsKeyboardManagerInput(controller.keyboard)).toBe(false); + controller.resume(); + expect(acceptsKeyboardManagerInput(controller.keyboard)).toBe(true); + + controller.keyboard.setEnabled(false); + controller.suspend(); + controller.resume(); + expect(acceptsKeyboardManagerInput(controller.keyboard)).toBe(false); + controller.dispose(); + }); + + it('still lays the reader out for the view that has it', () => { + const frames = stubFrames(); + const fixture = createReaderFixture(3, 0); + const controller = createController(fixture.reader, createCanvas()); + const layouts = vi.fn(); + controller.on('layoutChange', layouts); + + controller.suspend(); + fixture.updateLayout.mockReturnValue(true); + controller.resize(500, 600); + controller.setTheme({ backgroundColor: '#000000', foregroundColor: '#ffffff' }); + frames.runAll(); + + expect(fixture.updateLayout).toHaveBeenCalledWith(500, 600, undefined, undefined); + expect(fixture.setTheme).toHaveBeenCalledOnce(); + expect(layouts).not.toHaveBeenCalled(); + expect(fixture.renderSpreadTo).not.toHaveBeenCalled(); + controller.dispose(); + }); + + it('is disposed whole while suspended', () => { + stubFrames(); + const canvas = createCanvas(); + const controller = createController(createReaderFixture(3, 0).reader, canvas); + controller.suspend(); + controller.dispose(); + expect(controller.suspended).toBe(false); + controller.resume(); + expect(canvas.isConnected).toBe(false); + }); +}); + +function stubFrames(): { readonly runAll: () => void } { + let nextId = 0; + const pending = new Map(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextId; + pending.set(id, callback); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + pending.delete(id); + }); + vi.stubGlobal( + 'requestIdleCallback', + vi.fn(() => 1000), + ); + vi.stubGlobal('cancelIdleCallback', vi.fn()); + return { + runAll: () => { + for (let round = 0; round < 10 && pending.size > 0; round += 1) { + const callbacks = [...pending.values()]; + pending.clear(); + for (const callback of callbacks) callback(16 * (round + 1)); + } + }, + }; +} + +function createCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + const fake = createFakeGl(canvas); + canvas.getContext = vi.fn((kind: string) => + kind === 'webgl2' ? fake.gl : null, + ) as unknown as typeof canvas.getContext; + return canvas; +} + +/** A reader whose layout another view can replace: `commit` lays it out with that many spreads. */ +function createReaderFixture( + spreadCount: number, + active: number, +): { + readonly reader: Reader; + readonly renderSpreadTo: ReturnType; + readonly notifyActiveSpread: ReturnType; + readonly updateLayout: ReturnType; + readonly setTheme: ReturnType; + readonly commit: (spreadCount: number, active: number) => void; +} { + const committed = new Set<(activeSpreadIndex: number) => void>(); + const spreadsOf = (count: number) => + Array.from({ length: count }, (_, index) => ({ + index, + pageIndexes: [index], + leftPageIndex: index, + })); + const renderSpreadTo = vi.fn(() => true); + const notifyActiveSpread = vi.fn(); + const updateLayout = vi.fn(() => false); + const setTheme = vi.fn(); + const reader = { + metadata: { title: 'Demo' }, + totalSpreads: spreadCount, + toc: [], + chapterMap: new Map(), + manifestHrefMap: new Map(), + pageCount: spreadCount, + spreads: spreadsOf(spreadCount), + activeSpreadIndex: active, + interactions: readerInteractions(), + dpr: 1, + renderSpread: vi.fn(), + renderSpreadTo, + notifyActiveSpread, + resize: vi.fn(), + setDevicePixelRatio: vi.fn(), + setSpreadMode: vi.fn(() => false), + updateLayout, + setTheme, + findPage: vi.fn(), + findSpread: vi.fn(), + resolveTocEntry: vi.fn(), + findActiveTocEntry: vi.fn(), + getCanvasSize: vi.fn(() => ({ width: 300, height: 400 })), + getLayoutGeometry: vi.fn(() => ({ + viewportWidth: 300, + viewportHeight: 400, + marginLeft: 0, + marginTop: 0, + spreadGap: 0, + })), + getImageBlobUrl: vi.fn(), + setTypography: vi.fn(() => false), + onSpreadRendered: vi.fn(() => () => undefined), + onLayoutCommitted: (listener: (activeSpreadIndex: number) => void) => { + committed.add(listener); + return () => committed.delete(listener); + }, + dispose: vi.fn(), + }; + const commit = (count: number, activeIndex: number): void => { + reader.totalSpreads = count; + reader.pageCount = count; + reader.spreads = spreadsOf(count); + reader.activeSpreadIndex = activeIndex; + for (const listener of [...committed]) listener(activeIndex); + }; + return { + reader: reader as unknown as Reader, + renderSpreadTo, + notifyActiveSpread, + updateLayout, + setTheme, + commit, + }; +} diff --git a/packages/kit/tests/helpers/native-selection.ts b/packages/kit/tests/helpers/native-selection.ts index 545a68573..36775abbe 100644 --- a/packages/kit/tests/helpers/native-selection.ts +++ b/packages/kit/tests/helpers/native-selection.ts @@ -60,7 +60,8 @@ export function point(x: number) { export function caret(textOffset: number, pageIndex = 0, href = 'chapter.xhtml'): ReaderTextCaret { return { pageIndex, - geometry: { x: textOffset, y: 0, height: 18 }, + geometry: { x: textOffset, y: 0, width: 0, height: 18 }, + writingMode: 'horizontal-tb', sourceLocator: { href, sourcePoint: { nodePath: [0], textOffset }, diff --git a/packages/kit/tests/helpers/scroll-view.ts b/packages/kit/tests/helpers/scroll-view.ts new file mode 100644 index 000000000..db8fbf964 --- /dev/null +++ b/packages/kit/tests/helpers/scroll-view.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, vi } from 'vitest'; +import type { Reader, ReaderScrollLayout } from '@ritojs/core'; +import { createScrollController } from '../../src/scroll/controller'; +import { createFakeGl } from './fake-gl'; +import { capabilityFrom, caret, exactRange, resolvedCaret } from './native-selection'; + +export const LAYOUT: ReaderScrollLayout = { + axis: 'down', + windowLength: 600, + chapters: [ + { firstSpread: 0, spreadCount: 2, extent: 900 }, + { firstSpread: 2, spreadCount: 3, extent: 1_500 }, + ], +}; + +/** A reader on a scrolled layout, 400 × 600 at ratio 1, margins 30. */ +export function fakeReader() { + const committed: (() => void)[] = []; + const invalidated: ((spreadIndex: number) => void)[] = []; + const reader = { + dpr: 1, + activeSpreadIndex: 0, + spreads: Array.from({ length: 5 }, (_, index) => ({ index, leftPageIndex: index })), + painted: [] as number[], + active: [] as number[], + getLayoutGeometry: () => ({ marginTop: 30, marginRight: 30 }), + layout: LAYOUT, + getScrollLayout: () => reader.layout, + getCanvasSize: () => ({ width: 400, height: 600 }), + renderSpreadTo(spreadIndex: number) { + reader.painted.push(spreadIndex); + return true; + }, + notifyActiveSpread(spreadIndex: number) { + reader.active.push(spreadIndex); + reader.activeSpreadIndex = spreadIndex; + }, + onLayoutCommitted(listener: () => void) { + committed.push(listener); + return () => undefined; + }, + onSpreadContentInvalidated(listener: (spreadIndex: number) => void) { + invalidated.push(listener); + return () => undefined; + }, + commit: () => { + for (const listener of committed) listener(); + }, + }; + return reader; +} + +export function fakeCanvas() { + const fake = createFakeGl(); + const canvas = document.createElement('canvas'); + vi.spyOn(canvas, 'getContext').mockImplementation(((kind: string) => + kind === 'webgl2' ? fake.gl : null) as never); + return canvas; +} + +/** A view over a reader that selects text, scrolled 500 px down, and a mouse pointer event maker. */ +export function selectingView() { + const fills = stubOffscreenCanvas(); + const reader = fakeReader(); + Object.assign(reader, { + getLayoutGeometry: () => ({ marginTop: 30, marginRight: 30, marginLeft: 20 }), + interactions: { + enabled: true, + textSelection: capabilityFrom( + (point) => Promise.resolve(resolvedCaret(caret(Math.round(point.x), point.pageIndex))), + (anchor, focus) => + Promise.resolve({ + status: 'resolved' as const, + range: { + ...exactRange(anchor, focus), + rects: [ + { pageIndex: 0, spreadIndex: 0, x: 10, y: 520, width: 100, height: 18 }, + { pageIndex: 1, spreadIndex: 1, x: 0, y: 0, width: 40, height: 18 }, + ], + }, + }), + ), + getPageTargets: () => Promise.resolve(undefined), + }, + }); + const canvas = fakeCanvas(); + canvas.setPointerCapture = () => undefined; + Object.defineProperty(canvas, 'clientWidth', { value: 400, configurable: true }); + Object.defineProperty(canvas, 'clientHeight', { value: 600, configurable: true }); + const controller = createScrollController(reader as unknown as Reader, canvas); + const pointer = (type: string, x: number, y: number) => + new PointerEvent(type, { pointerType: 'mouse', button: 0, clientX: x, clientY: y, detail: 1 }); + return { canvas, controller, fills, pointer, reader }; +} + +/** Stands in for the overlay canvases, recording every rect filled, scaled. */ +export function stubOffscreenCanvas(): number[][] { + const fills: number[][] = []; + class Offscreen { + constructor( + public width: number, + public height: number, + ) {} + getContext() { + let scale = 1; + return { + canvas: this, + clearRect: () => undefined, + save: () => undefined, + restore: () => undefined, + scale: (by: number) => { + scale = by; + }, + fillRect: (x: number, y: number, width: number, height: number) => { + fills.push([x * scale, y * scale, width * scale, height * scale]); + }, + fillStyle: '', + }; + } + } + vi.stubGlobal('OffscreenCanvas', Offscreen); + return fills; +} + +let frames: FrameRequestCallback[] = []; +let now = 0; +/** Runs animation frames 16 ms apart until none is asked for. */ +export function runFrames(limit = 500): void { + for (let frame = 0; frame < limit && frames.length > 0; frame += 1) { + const due = frames; + frames = []; + now += 16; + for (const callback of due) callback(now); + } +} + +/** Frames run by hand and the clock they read, in each test of the file that calls it. */ +export function stubScrollFrames(): void { + beforeEach(() => { + frames = []; + now = 0; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => undefined); + vi.spyOn(performance, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); +} diff --git a/packages/kit/tests/keyboard-selection-map.test.ts b/packages/kit/tests/keyboard-selection-map.test.ts index cbcae9265..6f9c6329f 100644 --- a/packages/kit/tests/keyboard-selection-map.test.ts +++ b/packages/kit/tests/keyboard-selection-map.test.ts @@ -36,6 +36,32 @@ describe('platform keyboard selection mapping', () => { expect(keyboardSelectionMovement(event(key, modifiers), false)).toBe(expected); }); + it.each([ + ['ArrowDown', {}, 'characterRight'], + ['ArrowUp', {}, 'characterLeft'], + ['ArrowLeft', {}, 'lineDown'], + ['ArrowRight', {}, 'lineUp'], + ['ArrowDown', { altKey: true }, 'wordRight'], + ['ArrowLeft', { altKey: true }, 'paragraphForward'], + ['PageDown', {}, 'pageDown'], + ] as const)( + 'reads macOS Shift+%s with %o on a vertical-rl line as %s', + (key, modifiers, expected) => { + expect(keyboardSelectionMovement(event(key, modifiers), true, 'vertical-rl')).toBe(expected); + }, + ); + + it.each([ + ['ArrowDown', { ctrlKey: true }, 'wordStartRight'], + ['ArrowLeft', { ctrlKey: true }, 'paragraphNextStart'], + ['Home', {}, 'lineStart'], + ] as const)( + 'reads Windows/Linux Shift+%s with %o on a vertical-rl line as %s', + (key, modifiers, expected) => { + expect(keyboardSelectionMovement(event(key, modifiers), false, 'vertical-rl')).toBe(expected); + }, + ); + it('rejects non-selection, composing, and mixed-modifier chords', () => { expect(keyboardSelectionMovement(event('ArrowLeft', { shiftKey: false }), false)).toBeNull(); expect(keyboardSelectionMovement(event('ArrowLeft', { isComposing: true }), false)).toBeNull(); diff --git a/packages/kit/tests/keyboard-selection-wiring.test.ts b/packages/kit/tests/keyboard-selection-wiring.test.ts index 29ef22216..e2c9c462c 100644 --- a/packages/kit/tests/keyboard-selection-wiring.test.ts +++ b/packages/kit/tests/keyboard-selection-wiring.test.ts @@ -248,7 +248,11 @@ function createKeyboardSelectionHarness( supportsProjectionTransfer: true, }; registerSelectionInteractionOwner(selection, () => interactionGeneration, gestureOwner); - registerSelectionKeyboardOwner(selection, { canExtend: () => canExtend, begin }); + registerSelectionKeyboardOwner(selection, { + canExtend: () => canExtend, + focusWritingMode: () => 'horizontal-tb', + begin, + }); return { selection, begin, commit }; } diff --git a/packages/kit/tests/lifecycle.test.ts b/packages/kit/tests/lifecycle.test.ts index c0f02e5e1..5c40ea300 100644 --- a/packages/kit/tests/lifecycle.test.ts +++ b/packages/kit/tests/lifecycle.test.ts @@ -71,7 +71,7 @@ describe('controller lifecycle', () => { coordState: { activeImageBlobUrl: null, contentInteractionGeneration: 0 }, frameDriver: { animating: false, onRest: () => () => undefined }, } as unknown as WiringDeps; - const lifecycle = buildLifecycle(createDisposableCollection(), runtime, deps); + const lifecycle = buildLifecycle(createDisposableCollection(), runtime, deps).slice; lifecycle.mount(firstContainer); lifecycle.mount(firstContainer); @@ -130,7 +130,7 @@ describe('controller lifecycle', () => { coordState: { activeImageBlobUrl: null, contentInteractionGeneration: 0 }, frameDriver: { animating: false, onRest: () => () => undefined }, } as unknown as WiringDeps; - const lifecycle = buildLifecycle(createDisposableCollection(), runtime, deps); + const lifecycle = buildLifecycle(createDisposableCollection(), runtime, deps).slice; lifecycle.mount(firstContainer); expect(canvas.getAttribute('data-rito-reader-surface')).toBe('true'); @@ -180,7 +180,7 @@ describe('controller lifecycle', () => { coordState: { activeImageBlobUrl: null, contentInteractionGeneration: 0 }, frameDriver: { animating: false, onRest: () => () => undefined }, } as unknown as WiringDeps; - const lifecycle = buildLifecycle(createDisposableCollection(), runtime, deps); + const lifecycle = buildLifecycle(createDisposableCollection(), runtime, deps).slice; lifecycle.mount(firstContainer); expect(() => { @@ -221,7 +221,7 @@ describe('controller lifecycle', () => { contentInteractionGeneration: 0, }, } as unknown as WiringDeps; - const lifecycle = buildLifecycle(disposables, runtime, deps); + const lifecycle = buildLifecycle(disposables, runtime, deps).slice; expect(() => { lifecycle.dispose(); diff --git a/packages/kit/tests/native-selection-caret-projection.test.ts b/packages/kit/tests/native-selection-caret-projection.test.ts new file mode 100644 index 000000000..600198a62 --- /dev/null +++ b/packages/kit/tests/native-selection-caret-projection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { projectNativeSelectionCaret } from '../src/interaction/selection/native-adapter-projection'; +import type { NativeSelectionProjection } from '../src/interaction/selection/engine-types'; + +const shifted: NativeSelectionProjection = { + spreadContentToPage: () => null, + isPageVisible: (pageIndex) => pageIndex === 0, + pageContentToSpread: (_, rect) => ({ ...rect, x: rect.x + 100, y: rect.y + 10 }), +}; + +describe('native selection caret projection', () => { + it('keeps a caret on a horizontal line upright, with no width', () => { + const caret = { pageIndex: 0, geometry: { x: 12, y: 30, width: 0, height: 18 } }; + expect(projectNativeSelectionCaret(shifted, caret)).toEqual({ + x: 112, + y: 40, + width: 0, + height: 18, + }); + }); + + it('keeps a caret down a vertical line across its column, with no height', () => { + const caret = { pageIndex: 0, geometry: { x: 340, y: 64, width: 23, height: 0 } }; + expect(projectNativeSelectionCaret(shifted, caret)).toEqual({ + x: 440, + y: 74, + width: 23, + height: 0, + }); + }); + + it('projects no caret on a page the spread does not show', () => { + const caret = { pageIndex: 1, geometry: { x: 340, y: 64, width: 23, height: 0 } }; + expect(projectNativeSelectionCaret(shifted, caret)).toBeNull(); + }); +}); diff --git a/packages/kit/tests/native-spread-wiring.test.ts b/packages/kit/tests/native-spread-wiring.test.ts index 011e86f4c..b42ed6b12 100644 --- a/packages/kit/tests/native-spread-wiring.test.ts +++ b/packages/kit/tests/native-spread-wiring.test.ts @@ -52,6 +52,7 @@ describe('native target spread lifecycle', () => { frameDriver: { markOverlayDirty: vi.fn() }, getCurrentSpread: () => currentSpread, getRenderScale: () => 1, + isSuspended: () => false, } as unknown as WiringDeps; const disposables = createDisposableCollection(); wireSpreadRendered(deps, disposables); @@ -116,6 +117,7 @@ describe('native target spread lifecycle', () => { frameDriver: { markOverlayDirty }, getCurrentSpread: () => currentSpread, getRenderScale: () => 1, + isSuspended: () => false, } as unknown as WiringDeps; const disposables = createDisposableCollection(); wireSpreadRendered(deps, disposables); @@ -198,6 +200,7 @@ describe('native target spread lifecycle', () => { getRenderScale: () => 1, notifyNavigationContentReady: vi.fn(), syncViewport: vi.fn(), + isSuspended: () => false, } as unknown as WiringDeps; const disposables = createDisposableCollection(); wireSpreadRendered(deps, disposables); @@ -263,6 +266,7 @@ describe('native target spread lifecycle', () => { getCurrentSpread: () => 0, getRenderScale: () => 1, notifyNavigationContentReady: vi.fn(), + isSuspended: () => false, } as unknown as WiringDeps; const disposables = createDisposableCollection(); wireSpreadRendered(deps, disposables); @@ -311,6 +315,7 @@ describe('native target spread lifecycle', () => { getRenderScale: () => 1, notifyNavigationContentReady, prerenderAdjacent, + isSuspended: () => false, } as unknown as WiringDeps; const disposables = createDisposableCollection(); wireSpreadRendered(deps, disposables); diff --git a/packages/kit/tests/position-actions.test.ts b/packages/kit/tests/position-actions.test.ts index 65a4ae23b..a94e9afc7 100644 --- a/packages/kit/tests/position-actions.test.ts +++ b/packages/kit/tests/position-actions.test.ts @@ -644,6 +644,7 @@ function createInternals( coordState: createCoordinatorState(), positionPersistence: createPositionPersistence(positionStorage as never), restoreCompleted: false, + suspended: false, } as unknown as Internals; } diff --git a/packages/kit/tests/position-persistence.test.ts b/packages/kit/tests/position-persistence.test.ts index 27c231fb0..5df89170d 100644 --- a/packages/kit/tests/position-persistence.test.ts +++ b/packages/kit/tests/position-persistence.test.ts @@ -20,6 +20,7 @@ describe('position persistence wiring', () => { engines: { position: tracker }, emitter: { emit }, hasRestored: () => true, + isSuspended: () => false, positionPersistence: { save: vi.fn(() => Promise.reject(failure)) }, }; const disposables = createDisposableCollection(); @@ -52,6 +53,7 @@ describe('position persistence wiring', () => { engines: { position: tracker }, emitter, hasRestored: () => true, + isSuspended: () => false, positionPersistence: { save: vi.fn(() => pendingSave.promise) }, }; const disposables = createDisposableCollection(); @@ -87,6 +89,7 @@ describe('position persistence wiring', () => { engines: { position: tracker }, emitter, hasRestored: () => true, + isSuspended: () => false, positionPersistence: { save: vi.fn(() => Promise.reject(new Error('quota exceeded'))) }, }; const disposables = createDisposableCollection(); diff --git a/packages/kit/tests/position-preloaded-restore.test.ts b/packages/kit/tests/position-preloaded-restore.test.ts index 76342b569..21a85fde3 100644 --- a/packages/kit/tests/position-preloaded-restore.test.ts +++ b/packages/kit/tests/position-preloaded-restore.test.ts @@ -50,6 +50,7 @@ function createInternals( coordState: createCoordinatorState(), positionPersistence: createPositionPersistence(positionStorage as never), restoreCompleted: false, + suspended: false, } as unknown as Internals; } diff --git a/packages/kit/tests/scroll-composite.test.ts b/packages/kit/tests/scroll-composite.test.ts new file mode 100644 index 000000000..b26556164 --- /dev/null +++ b/packages/kit/tests/scroll-composite.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import type { ReaderGlTarget, ReaderScrollLayout } from '@ritojs/core'; +import { createCompositor } from '../src/painter/gl/compositor'; +import { compositeWindows, windowRect } from '../src/scroll/composite'; +import { createScrollGeometry } from '../src/scroll/geometry'; +import { createWindowPool } from '../src/scroll/pool'; +import { createFakeGl } from './helpers/fake-gl'; + +const layout = (axis: ReaderScrollLayout['axis']): ReaderScrollLayout => ({ + axis, + windowLength: 400, + chapters: [ + { firstSpread: 0, spreadCount: 2, extent: 600 }, + { firstSpread: 2, spreadCount: 3, extent: 1_000 }, + ], +}); + +describe('scrolled windows on the view', () => { + it('stacks windows down the view, or leftwards from its right edge', () => { + const down = createScrollGeometry(layout('down'), 2); + const [first, second] = down.windows; + if (!first || !second) throw new Error('two windows'); + expect(windowRect(down, second, 300, 800, 800)).toEqual({ + x: 0, + y: 500, + width: 800, + height: 400, + }); + const left = createScrollGeometry(layout('left'), 2); + const [right, next] = left.windows; + if (!right || !next) throw new Error('two windows'); + // The first window ends at the view's right edge, where a page is. + expect(windowRect(left, right, 0, 800, 800)).toEqual({ x: 0, y: 0, width: 800, height: 800 }); + expect(windowRect(left, next, 0, 800, 800)).toEqual({ x: -400, y: 0, width: 400, height: 800 }); + }); + + it('draws every window on the screen, even one just painted into its own framebuffer', () => { + const fake = createFakeGl(); + const compositor = createCompositor(fake.gl); + const geometry = createScrollGeometry(layout('down'), 1); + // A window painted as the reader paints one: its framebuffer left bound. + const pool = createWindowPool(fake.gl, (_, target: ReaderGlTarget) => { + fake.gl.bindFramebuffer(fake.gl.FRAMEBUFFER, target.framebuffer); + return true; + }); + compositor.bind(null, 400, 400); + const before = fake.draws.length; + compositeWindows(compositor, geometry, 150, 400, 400, (window, rect) => + pool.picture(window.spreadIndex, rect.width, rect.height), + ); + const composed = fake.draws.slice(before); + expect(composed).toHaveLength(2); + for (const draw of composed) expect(draw.framebuffer).toBeNull(); + pool.dispose(); + }); + + it('draws what is painted at whole pixels and paints a window once', () => { + const fake = createFakeGl(); + const compositor = createCompositor(fake.gl); + const geometry = createScrollGeometry(layout('down'), 1); + const painted: [number, number, number][] = []; + const loaded = new Set([0]); + const pool = createWindowPool(fake.gl, (spreadIndex, target: ReaderGlTarget) => { + painted.push([spreadIndex, target.width, target.height]); + return loaded.has(spreadIndex); + }); + const draw = () => + compositeWindows(compositor, geometry, 150.4, 400, 400, (window, rect) => + pool.picture(window.spreadIndex, rect.width, rect.height), + ); + expect(draw().map((window) => window.spreadIndex)).toEqual([1]); + loaded.add(1); + expect(draw()).toEqual([]); + expect(draw()).toEqual([]); + // The chapter's 200-row tail window is painted at its own size, each once. + expect(painted).toEqual([ + [0, 400, 400], + [1, 400, 200], + [1, 400, 200], + ]); + pool.invalidate(0); + draw(); + expect(painted.at(-1)).toEqual([0, 400, 400]); + pool.keep(new Set([1])); + pool.dispose(); + }); +}); diff --git a/packages/kit/tests/scroll-controller.test.ts b/packages/kit/tests/scroll-controller.test.ts new file mode 100644 index 000000000..a4f945b96 --- /dev/null +++ b/packages/kit/tests/scroll-controller.test.ts @@ -0,0 +1,586 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest'; +import type { Reader } from '@ritojs/core'; +import { createScrollController } from '../src/scroll/controller'; +import { caret, exactRange, flushMicrotasks } from './helpers/native-selection'; +import { + fakeCanvas, + fakeReader, + LAYOUT, + runFrames, + selectingView, + stubScrollFrames, +} from './helpers/scroll-view'; + +stubScrollFrames(); + +describe('scroll controller', () => { + it('shows the windows end to end and tells the reader which one is read', () => { + const reader = fakeReader(); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + const windows: number[] = []; + controller.on('window', ({ spreadIndex }) => windows.push(spreadIndex)); + controller.mount(document.body); + runFrames(); + expect(controller.axis).toBe('down'); + expect(controller.extent).toBe(2_400); + // The first window and the one after it, painted ahead. + expect(reader.painted).toEqual([0, 1]); + + // The reading line, 30 px down, passes into the second window at 570. + controller.scrollTo(569); + runFrames(); + expect(reader.active).toEqual([]); + controller.scrollTo(571); + runFrames(); + expect(controller.currentSpread).toBe(1); + expect(reader.active).toEqual([1]); + expect(windows).toEqual([1]); + + // A window shown from a line 100 px into it puts that line at the reading line. + controller.scrollToSpread(3, 100); + runFrames(); + expect(controller.offset).toBe(900 + 600 + 100 - 30); + expect(controller.currentSpread).toBe(3); + controller.dispose(); + }); + + it('keeps the window under the reading line through a new layout', () => { + const reader = fakeReader(); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + controller.mount(document.body); + controller.scrollToSpread(3, 250); + runFrames(); + reader.dpr = 2; + reader.commit(); + runFrames(); + expect(controller.currentSpread).toBe(3); + expect(controller.offset).toBe(900 + 600 + 250 - 30); + controller.dispose(); + }); + + it('keeps the window the reader reads when a layout numbers the windows anew', () => { + const reader = fakeReader(); + // The second chapter laid out alone: its windows count from 0. + reader.layout = { ...LAYOUT, chapters: [{ firstSpread: 0, spreadCount: 3, extent: 1_500 }] }; + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + controller.mount(document.body); + controller.scrollToSpread(1, 250); + runFrames(); + expect(reader.activeSpreadIndex).toBe(1); + // The whole book: that window is the fourth, and the reader says so. + reader.layout = LAYOUT; + reader.activeSpreadIndex = 3; + reader.commit(); + runFrames(); + expect(controller.currentSpread).toBe(3); + expect(controller.offset).toBe(900 + 600 + 250 - 30); + controller.dispose(); + }); + + it('mounted again where it is, keeps its place and lays nothing out again', () => { + const reader = fakeReader(); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + const rests: number[] = []; + controller.on('rest', ({ offset }) => rests.push(offset)); + controller.mount(document.body); + controller.scrollToSpread(3, 250); + runFrames(); + expect(rests).toEqual([900 + 600 + 250 - 30]); + controller.mount(document.body); + controller.mount(document.body); + runFrames(); + expect(rests).toEqual([900 + 600 + 250 - 30]); + expect(controller.offset).toBe(900 + 600 + 250 - 30); + controller.dispose(); + }); + + it('can be moved before it is shown', () => { + const reader = fakeReader(); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + controller.scrollToSpread(3, 250); + controller.mount(document.body); + runFrames(); + expect(controller.offset).toBe(900 + 600 + 250 - 30); + controller.dispose(); + }); + + it('glides after a fling and comes to rest', () => { + const reader = fakeReader(); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + controller.mount(document.body); + runFrames(); + const rests: number[] = []; + controller.on('rest', ({ offset }) => rests.push(offset)); + controller.grab(); + controller.follow(100); + controller.release(1_000); + runFrames(); + expect(controller.offset).toBeGreaterThan(100 + 400); + expect(controller.offset).toBeLessThan(100 + 501); + expect(rests.at(-1)).toBe(controller.offset); + controller.dispose(); + }); + + it('reads the line at the reading line once the strip rests', async () => { + const reader = fakeReader(); + const points: unknown[] = []; + Object.assign(reader, { + spreads: Array.from({ length: 5 }, (_, index) => ({ index, leftPageIndex: index })), + interactions: { + textSelection: { + resolveCaret: (point: { pageIndex: number }) => { + points.push(point); + return Promise.resolve({ + status: 'resolved', + pageIndex: point.pageIndex, + spreadIndex: point.pageIndex, + caret: { sourceLocator: { href: 'two.xhtml', progression: 0.25 } }, + }); + }, + }, + }, + }); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + const positions: unknown[] = []; + controller.on('position', ({ locator }) => positions.push(locator)); + controller.mount(document.body); + controller.scrollToSpread(3, 100); + runFrames(); + await vi.waitFor(() => { + expect(positions).toEqual([{ href: 'two.xhtml', progression: 0.25 }]); + }); + // The reading line 100 px into the window, a pixel in from the content's start. + expect(points.at(-1)).toEqual({ pageIndex: 3, x: 1, y: 100 - 30 }); + controller.dispose(); + }); + + it('puts a located line at the reading line', async () => { + const reader = fakeReader(); + Object.assign(reader, { + navigateToLocator: () => + Promise.resolve({ status: 'resolved', spreadIndex: 2, pageIndex: 2, blockOffset: 240 }), + }); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + controller.mount(document.body); + expect(await controller.scrollToLocator({ href: 'two.xhtml', progression: 0.1 })).toBe(true); + runFrames(); + expect(controller.offset).toBe(900 + 240 - 30); + expect(controller.currentSpread).toBe(2); + controller.dispose(); + }); + + it('finds the window and content point under a view point, and taps what is there', async () => { + const reader = fakeReader(); + Object.assign(reader, { + getLayoutGeometry: () => ({ marginTop: 30, marginRight: 30, marginLeft: 20 }), + interactions: { + getPageTargets: (pageIndex: number) => + Promise.resolve({ + pageIndex, + spreadIndex: pageIndex, + // A link's own text run comes first, as a page lists them. + targets: [ + { kind: 'text', bounds: { x: 0, y: 40, width: 300, height: 20 }, label: 'to two' }, + { kind: 'link', bounds: { x: 0, y: 40, width: 100, height: 20 }, href: 'two.xhtml' }, + ], + }), + }, + }); + const canvas = fakeCanvas(); + const controller = createScrollController(reader as unknown as Reader, canvas); + const taps: { target: unknown }[] = []; + controller.on('tap', (tapped) => taps.push(tapped)); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + // 100 px down the view is 600 px into the book: the second window's top. + const point = controller.pointAt(30, 100); + expect(point).toEqual({ spreadIndex: 1, pageIndex: 1, x: 10, y: -30 }); + if (point === undefined) throw new Error('a point'); + expect(await controller.targetAt({ ...point, y: 45 })).toMatchObject({ href: 'two.xhtml' }); + canvas.dispatchEvent(new MouseEvent('click', { clientX: 30, clientY: 170 })); + await vi.waitFor(() => { + expect(taps).toHaveLength(1); + }); + expect(taps[0]?.target).toMatchObject({ href: 'two.xhtml' }); + controller.dispose(); + }); + + it('reads a leftward strip from the right edge of the view', () => { + const reader = fakeReader(); + Object.assign(reader, { + getScrollLayout: () => ({ ...LAYOUT, axis: 'left', windowLength: 400 }), + getLayoutGeometry: () => ({ marginTop: 30, marginRight: 30, marginLeft: 20 }), + }); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + controller.mount(document.body); + runFrames(); + expect(controller.pointAt(390, 50)).toEqual({ spreadIndex: 0, pageIndex: 0, x: 370, y: 20 }); + controller.dispose(); + }); + + it('selects across windows with a mouse drag, draws it over each and clears it with a click', async () => { + const { canvas, controller, fills, pointer } = selectingView(); + const selections: { text: string; rects: readonly unknown[] }[] = []; + controller.on('selection', (selection) => selections.push(selection)); + const taps: unknown[] = []; + controller.on('tap', (tapped) => taps.push(tapped)); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + + // Pressed in the first window, dragged into the second. + canvas.dispatchEvent(pointer('pointerdown', 30, 50)); + canvas.dispatchEvent(pointer('pointermove', 60, 200)); + canvas.dispatchEvent(pointer('pointerup', 60, 200)); + await flushMicrotasks(); + runFrames(); + // Held clear of the view's ends, the drag left the strip where it was. + expect(controller.offset).toBe(500); + expect(controller.selection?.selectedText).toBe('selected text'); + expect(selections.at(-1)?.text).toBe('selected text'); + // The first window's rect 500 px up the view, the second's 100 down, + // each in from its page's margins. + expect(controller.selectionRects()).toEqual([ + { x: 30, y: 50, width: 100, height: 18 }, + { x: 20, y: 130, width: 40, height: 18 }, + ]); + // Any page rect maps the same way, a page off screen to nothing. + expect( + controller.viewRects([ + { pageIndex: 1, x: 0, y: 0, width: 40, height: 18 }, + { pageIndex: 4, x: 0, y: 0, width: 40, height: 18 }, + ]), + ).toEqual([{ x: 20, y: 130, width: 40, height: 18 }]); + expect(fills).toEqual( + expect.arrayContaining([ + [30, 550, 100, 18], + [20, 30, 40, 18], + ]), + ); + expect(taps).toEqual([]); + + // A click selects nothing: it clears the selection and taps. + canvas.dispatchEvent(pointer('pointerdown', 60, 300)); + canvas.dispatchEvent(pointer('pointerup', 60, 300)); + await flushMicrotasks(); + expect(controller.selection).toBeNull(); + await vi.waitFor(() => { + expect(taps).toHaveLength(1); + }); + controller.dispose(); + }); + + it('holds the whole-book layout while a drag is selecting', async () => { + const { canvas, controller, pointer, reader } = selectingView(); + const released = vi.fn(); + const hold = vi.fn(() => released); + Object.assign(reader, { holdBookLayout: hold }); + controller.mount(document.body); + runFrames(); + canvas.dispatchEvent(pointer('pointerdown', 30, 50)); + canvas.dispatchEvent(pointer('pointermove', 60, 200)); + await flushMicrotasks(); + expect(hold).toHaveBeenCalledOnce(); + expect(released).not.toHaveBeenCalled(); + canvas.dispatchEvent(pointer('pointermove', 70, 210)); + canvas.dispatchEvent(pointer('pointerup', 70, 210)); + await flushMicrotasks(); + runFrames(); + expect(controller.selection?.selectedText).toBe('selected text'); + expect(hold).toHaveBeenCalledOnce(); + expect(released).toHaveBeenCalledOnce(); + controller.dispose(); + }); + + it('moves the focus with a Shift chord, read by its line, and keeps it on screen', async () => { + const { canvas, controller, pointer, reader } = selectingView(); + const movements: string[] = []; + // Each movement lands the focus on the third window's page, 500 px into + // its content box, on a vertical line. + const textSelection = (reader as unknown as { interactions: { textSelection: object } }) + .interactions.textSelection; + Object.assign(textSelection, { + resolveTextSelectionMovement: ({ + anchor, + movement, + }: { + anchor: ReturnType; + movement: string; + }) => { + movements.push(movement); + const focus = { + ...caret(40, 2), + geometry: { x: 40, y: 500, width: 0, height: 18 }, + writingMode: 'vertical-rl', + } as ReturnType; + return Promise.resolve({ status: 'resolved', range: exactRange(anchor, focus) }); + }, + }); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + canvas.dispatchEvent(pointer('pointerdown', 30, 50)); + canvas.dispatchEvent(pointer('pointermove', 60, 200)); + canvas.dispatchEvent(pointer('pointerup', 60, 200)); + await flushMicrotasks(); + runFrames(); + + const shift = (key: string) => + new KeyboardEvent('keydown', { key, shiftKey: true, cancelable: true }); + window.dispatchEvent(shift('ArrowDown')); + await flushMicrotasks(); + runFrames(); + expect(movements).toEqual(['lineDown']); + // The third window starts 900 px in: the focus 930 px down the view, + // brought in to 40 px above its bottom. + expect(controller.offset).toBe(500 + (900 - 500 + 30 + 500 + 18) - (600 - 40)); + + // On a vertical line, left moves on to the next line. + window.dispatchEvent(shift('ArrowLeft')); + await flushMicrotasks(); + expect(movements).toEqual(['lineDown', 'lineDown']); + controller.dispose(); + }); + + it('selects the word under a double press and the paragraph under a triple one', async () => { + const { canvas, controller, pointer, reader } = selectingView(); + const asked: string[] = []; + const textSelection = (reader as unknown as { interactions: { textSelection: object } }) + .interactions.textSelection; + Object.assign(textSelection, { + resolveTextRangeFromPoints: ({ granularity }: { granularity: string }) => { + asked.push(granularity); + return Promise.resolve({ + status: 'resolved', + range: exactRange(caret(10, 0), caret(20, 0)), + }); + }, + }); + controller.mount(document.body); + runFrames(); + for (const [clicks, granularity] of [ + [2, 'word'], + [3, 'paragraph'], + ] as const) { + canvas.dispatchEvent(pointer('pointerdown', 30, 50)); + canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, detail: clicks })); + canvas.dispatchEvent(pointer('pointerup', 30, 50)); + await flushMicrotasks(); + expect(asked.at(-1)).toBe(granularity); + expect(controller.selection?.selectedText).toBe('selected text'); + } + controller.dispose(); + }); + + it('selects the word under a touch held still and follows it, without scrolling', async () => { + const { canvas, controller, reader } = selectingView(); + const words: string[] = []; + const textSelection = (reader as unknown as { interactions: { textSelection: object } }) + .interactions.textSelection; + Object.assign(textSelection, { + resolveTextRangeFromPoints: ({ + anchor, + focus, + granularity, + }: { + anchor: { pageIndex: number; x: number }; + focus: { pageIndex: number; x: number }; + granularity: string; + }) => { + words.push(granularity); + return Promise.resolve({ + status: 'resolved', + range: exactRange( + caret(Math.round(anchor.x), anchor.pageIndex), + caret(Math.round(focus.x), focus.pageIndex), + ), + }); + }, + }); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + const touch = (type: string, x: number, y: number) => + new PointerEvent(type, { pointerType: 'touch', isPrimary: true, clientX: x, clientY: y }); + canvas.dispatchEvent(touch('pointerdown', 30, 50)); + await new Promise((resolve) => setTimeout(resolve, 400)); + canvas.dispatchEvent(touch('pointermove', 60, 200)); + canvas.dispatchEvent(touch('pointerup', 60, 200)); + await flushMicrotasks(); + runFrames(); + expect(words[0]).toBe('word'); + expect(controller.selection?.selectedText).toBe('selected text'); + expect(controller.offset).toBe(500); + controller.dispose(); + }); + + it("gives the selection's ends as handles and moves an end dragged by its handle", async () => { + const { canvas, controller, pointer, reader } = selectingView(); + const textSelection = ( + reader as unknown as { + interactions: { textSelection: { resolveTextRangeToPoint: () => unknown } }; + } + ).interactions.textSelection; + const asked = vi.spyOn(textSelection, 'resolveTextRangeToPoint'); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + canvas.dispatchEvent(pointer('pointerdown', 30, 50)); + canvas.dispatchEvent(pointer('pointermove', 60, 200)); + canvas.dispatchEvent(pointer('pointerup', 60, 200)); + await flushMicrotasks(); + runFrames(); + const handles = controller.selectionHandles(); + // The focus, dragged to view x 60 over the second window, is the end: + // where the pointer let go, at the top of that window's content box. + expect(handles?.focusEdge).toBe('end'); + expect(handles?.end).toEqual({ x: 60, y: 130, width: 0, height: 18 }); + const before = asked.mock.calls.length; + const drag = controller.beginSelectionHandleDrag('end', { clientX: 80, clientY: 130 }); + expect(drag).not.toBeNull(); + drag?.update({ clientX: 120, clientY: 300 }); + drag?.finish({ clientX: 120, clientY: 300 }); + await flushMicrotasks(); + runFrames(); + expect(asked.mock.calls.length).toBeGreaterThan(before); + expect(controller.selection?.selectedText).toBe('selected text'); + controller.dispose(); + }); + + it('scrolls on under a drag held near the end of the view until the button lets go', async () => { + const { canvas, controller, pointer } = selectingView(); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + canvas.dispatchEvent(pointer('pointerdown', 30, 50)); + // 10 px from the view's bottom: 18 px a frame. + canvas.dispatchEvent(pointer('pointermove', 30, 590)); + await flushMicrotasks(); + runFrames(5); + expect(controller.offset).toBeGreaterThan(500); + canvas.dispatchEvent(pointer('pointerup', 30, 590)); + const held = controller.offset; + runFrames(); + expect(controller.offset).toBe(held); + controller.dispose(); + }); + + it('mirrors the text of the windows on screen once the strip rests, and taps a link activated there', async () => { + const reader = fakeReader(); + const nodes = (pageIndex: number) => [ + { + role: 'paragraph' as const, + text: `page ${String(pageIndex)}`, + bounds: { x: 0, y: 0, width: 300, height: 20 }, + children: [], + }, + { + role: 'link' as const, + text: `to ${String(pageIndex + 1)}`, + href: 'next.xhtml', + bounds: { x: 0, y: 40, width: 60, height: 20 }, + children: [], + }, + ]; + Object.assign(reader, { + interactions: { + enabled: true, + getPageSemantics: (pageIndex: number) => + Promise.resolve({ pageIndex, spreadIndex: pageIndex, nodes: nodes(pageIndex) }), + getPageTargets: (pageIndex: number) => + Promise.resolve({ + pageIndex, + spreadIndex: pageIndex, + targets: [ + { kind: 'link', bounds: { x: 0, y: 40, width: 60, height: 20 }, href: 'next.xhtml' }, + ], + }), + }, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + a11y: { enabled: true }, + }); + const taps: { point: { spreadIndex: number } }[] = []; + controller.on('tap', (tapped) => taps.push(tapped)); + controller.mount(container); + // The mirror goes in as an idle task once the frame after rest is drawn. + const mirrors = async (text: string) => { + await vi.waitFor(() => { + runFrames(); + expect(container.textContent).toContain(text); + }); + }; + await mirrors('page 0'); + expect(container.textContent).not.toContain('page 1'); + + // Scrolled so the first two windows share the screen: both pages are read. + controller.scrollTo(500); + await mirrors('page 1'); + expect(container.textContent).toContain('page 0'); + + const links = container.querySelectorAll('a'); + links[1]?.dispatchEvent(new MouseEvent('click', { cancelable: true })); + await vi.waitFor(() => { + expect(taps).toHaveLength(1); + }); + expect(taps[0]?.point.spreadIndex).toBe(1); + controller.dispose(); + expect(container.querySelector('[aria-live]')).toBeNull(); + container.remove(); + }); + + it('comes to rest once for each place, not again for a frame drawn there', () => { + const reader = fakeReader(); + const controller = createScrollController(reader as unknown as Reader, fakeCanvas(), { + input: false, + }); + const rests: number[] = []; + controller.on('rest', ({ offset }) => rests.push(offset)); + controller.mount(document.body); + runFrames(); + expect(rests).toEqual([0]); + // A highlight drawn over the windows shown moves nothing. + controller.setHighlights([{ id: 'search', color: 'orange', rects: [] }]); + runFrames(); + expect(rests).toEqual([0]); + controller.scrollTo(120); + runFrames(); + expect(rests).toEqual([0, 120]); + controller.dispose(); + }); + + it('moves with the wheel when its own input drives it', () => { + const reader = fakeReader(); + const canvas = fakeCanvas(); + const controller = createScrollController(reader as unknown as Reader, canvas); + controller.mount(document.body); + runFrames(); + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, cancelable: true })); + runFrames(); + expect(controller.offset).toBe(120); + controller.dispose(); + }); +}); diff --git a/packages/kit/tests/scroll-geometry.test.ts b/packages/kit/tests/scroll-geometry.test.ts new file mode 100644 index 000000000..237c606ec --- /dev/null +++ b/packages/kit/tests/scroll-geometry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { ReaderScrollLayout } from '@ritojs/core'; +import { createScrollGeometry, type ScrollWindow } from '../src/scroll/geometry'; + +function at(windows: readonly ScrollWindow[], index: number): ScrollWindow { + const window = windows[index]; + if (window === undefined) throw new Error(`no window ${String(index)}`); + return window; +} + +const LAYOUT: ReaderScrollLayout = { + axis: 'down', + windowLength: 950, + chapters: [ + { firstSpread: 0, spreadCount: 1, extent: 300.5 }, + { firstSpread: 1, spreadCount: 4, extent: 3_401.25 }, + { firstSpread: 5, spreadCount: 2, extent: 1_900 }, + ], +}; + +describe('scroll geometry', () => { + it('tiles each chapter by the engine rule and the chapters end to end', () => { + for (const ratio of [1, 1.25, 1.5, 2, 2.625, 3]) { + const geometry = createScrollGeometry(LAYOUT, ratio); + const { windows } = geometry; + expect(windows.map((window) => window.spreadIndex)).toEqual([0, 1, 2, 3, 4, 5, 6]); + for (let index = 1; index < windows.length; index += 1) { + const before = at(windows, index - 1); + expect(at(windows, index).start).toBe(before.start + before.length); + } + // A chapter's second window starts D(1) into it, the engine's row. + const second = Math.floor(ratio * 300.5 + 0.5); + expect(at(windows, 2).start - at(windows, 1).start).toBe(Math.floor(950 * ratio + 0.5)); + expect(at(windows, 1).start).toBe(second); + expect(geometry.extent).toBe( + Math.floor(300.5 * ratio + 0.5) + + Math.floor(3_401.25 * ratio + 0.5) + + Math.floor(1_900 * ratio + 0.5), + ); + for (const window of windows) expect(Number.isInteger(window.start)).toBe(true); + } + }); + + it('finds the window under an offset and the windows a viewport shows', () => { + const geometry = createScrollGeometry(LAYOUT, 1); + expect(geometry.windowAt(0)?.spreadIndex).toBe(0); + expect(geometry.windowAt(299)?.spreadIndex).toBe(0); + expect(geometry.windowAt(301)?.spreadIndex).toBe(1); + expect(geometry.windowAt(-50)?.spreadIndex).toBe(0); + expect(geometry.windowAt(1e9)?.spreadIndex).toBe(6); + // The cover's 301 rows, then chapter one's windows from row 301. + expect(geometry.visible(200, 950).map((window) => window.spreadIndex)).toEqual([0, 1]); + expect(geometry.visible(200, 1_100).map((window) => window.spreadIndex)).toEqual([0, 1, 2]); + expect(geometry.visible(301, 950).map((window) => window.spreadIndex)).toEqual([1]); + }); +}); diff --git a/packages/kit/tests/scroll-highlights.test.ts b/packages/kit/tests/scroll-highlights.test.ts new file mode 100644 index 000000000..baca1d901 --- /dev/null +++ b/packages/kit/tests/scroll-highlights.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { createScrollGeometry } from '../src/scroll/geometry'; +import { viewRects, windowLayers } from '../src/scroll/highlights'; +import { edgeSpeed } from '../src/scroll/selection-input'; + +const MARGINS = { marginLeft: 30, marginTop: 40 }; + +describe('scrolled highlights', () => { + it("draws a page's rects over the window showing it, the page's margins in", () => { + const layers = windowLayers(3, MARGINS, [ + { + id: 'search', + color: 'yellow', + rects: [ + { pageIndex: 3, x: 5, y: 6, width: 50, height: 18 }, + { pageIndex: 4, x: 5, y: 6, width: 50, height: 18 }, + ], + }, + { id: 'note', color: 'orange', zIndex: 2, mark: 'underline', rects: [] }, + ]); + expect(layers).toEqual([ + { + id: 'search', + color: 'yellow', + zIndex: 0, + mark: undefined, + rects: [{ x: 35, y: 46, width: 50, height: 18 }], + }, + { id: 'note', color: 'orange', zIndex: 2, mark: 'underline', rects: [] }, + ]); + }); + + it("places a page's rects where a downward view shows its window", () => { + // Windows 600 CSS pixels long at ratio 2: the second starts 1200 device + // pixels in, 200 below the top of a view scrolled 1000 down. + const geometry = createScrollGeometry( + { + axis: 'down', + windowLength: 600, + chapters: [{ firstSpread: 0, spreadCount: 3, extent: 1_800 }], + }, + 2, + ); + const rects = viewRects( + geometry, + (window) => window.spreadIndex + 10, + MARGINS, + 1_000, + 800, + 1_200, + [ + { pageIndex: 11, x: 5, y: 6, width: 50, height: 18 }, + { pageIndex: 14, x: 5, y: 6, width: 50, height: 18 }, + ], + ); + expect(rects).toEqual([{ x: 35, y: 100 + 46, width: 50, height: 18 }]); + }); + + it("places a page's rects where a leftward view shows its window", () => { + // A view 400 wide scrolled 100 leftwards: the first window's left edge + // is 100 in from the view's, the second's 300 out past it. + const geometry = createScrollGeometry( + { + axis: 'left', + windowLength: 400, + chapters: [{ firstSpread: 0, spreadCount: 3, extent: 1_200 }], + }, + 1, + ); + const rects = viewRects(geometry, (window) => window.spreadIndex, MARGINS, 100, 400, 600, [ + { pageIndex: 0, x: 300, y: 10, width: 23, height: 64 }, + { pageIndex: 1, x: 340, y: 10, width: 23, height: 64 }, + ]); + expect(rects).toEqual([ + { x: 100 + 30 + 300, y: 50, width: 23, height: 64 }, + { x: -300 + 30 + 340, y: 50, width: 23, height: 64 }, + ]); + }); + + it('scrolls the strip on near either end of a drag, faster nearer the end', () => { + const view = { clientWidth: 400, clientHeight: 600 }; + const down = { axis: 'down' as const }; + expect(edgeSpeed(view, down, { x: 200, y: 10 })).toBe(-18); + expect(edgeSpeed(view, down, { x: 200, y: -50 })).toBe(-24); + expect(edgeSpeed(view, down, { x: 200, y: 300 })).toBe(0); + expect(edgeSpeed(view, down, { x: 200, y: 590 })).toBe(18); + // Leftwards the block start is the right edge. + const left = { axis: 'left' as const }; + expect(edgeSpeed(view, left, { x: 395, y: 300 })).toBe(-21); + expect(edgeSpeed(view, left, { x: 5, y: 300 })).toBe(21); + }); +}); diff --git a/packages/kit/tests/scroll-physics.test.ts b/packages/kit/tests/scroll-physics.test.ts new file mode 100644 index 000000000..25fbfca68 --- /dev/null +++ b/packages/kit/tests/scroll-physics.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { defaultScrollPhysics as physics } from '../src/scroll/physics'; + +/** Runs a simulation in 16 ms steps until it rests; its offsets. */ +function run(simulation: ReturnType): number[] { + const offsets: number[] = []; + for (let frame = 1; frame < 2_000; frame += 1) { + const at = simulation(frame * 0.016); + offsets.push(at.offset); + if (at.done) return offsets; + } + throw new Error('the motion never rests'); +} + +describe('default scroll physics', () => { + it('follows a drag inside the book and resists past its ends', () => { + expect(physics.drag(500, 10_000, 950)).toBe(500); + const pulled = physics.drag(-300, 10_000, 950); + expect(pulled).toBeLessThan(0); + expect(pulled).toBeGreaterThan(-300); + const pushed = physics.drag(10_300, 10_000, 950) - 10_000; + expect(pushed).toBeCloseTo(-pulled); + }); + + it('glides after a fling, slowing, and rests', () => { + const offsets = run(physics.release(1_000, 3_000, 100_000)); + offsets.reduce((before, offset) => { + expect(offset).toBeGreaterThan(before); + return offset; + }, 1_000); + // It glides velocity / friction at most. + const last = offsets.at(-1) ?? 0; + expect(last).toBeLessThan(1_000 + 3_000 / 2); + expect(last).toBeGreaterThan(1_000 + 3_000 / 2 - 10); + }); + + it('springs back to the end it was pulled past, and to the end a fling reaches', () => { + const back = run(physics.release(-120, 0, 10_000)); + expect(back.at(-1)).toBe(0); + const flung = run(physics.release(9_900, 3_000, 10_000)); + expect(flung.at(-1)).toBe(10_000); + expect(Math.max(...flung)).toBeGreaterThan(10_000); + }); +}); diff --git a/packages/kit/tests/scroll-pointer.test.ts b/packages/kit/tests/scroll-pointer.test.ts new file mode 100644 index 000000000..3f5989617 --- /dev/null +++ b/packages/kit/tests/scroll-pointer.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest'; +import { runFrames, selectingView, stubScrollFrames } from './helpers/scroll-view'; + +stubScrollFrames(); + +describe('scroll controller pointing', () => { + it('says which of the app highlights a tap and a resting mouse are on, the top one first', async () => { + const { canvas, controller, pointer } = selectingView(); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + controller.setHighlights([ + { id: 'under', rects: [{ pageIndex: 1, x: 0, y: 0, width: 200, height: 40 }], color: 'red' }, + { + id: 'over', + rects: [ + { pageIndex: 1, x: 0, y: 0, width: 40, height: 18 }, + { pageIndex: 1, x: 0, y: 20, width: 30, height: 18 }, + ], + color: 'blue', + zIndex: 1, + }, + ]); + runFrames(); + const taps: { highlight: unknown }[] = []; + controller.on('tap', (tapped) => taps.push(tapped)); + const hovers: unknown[] = []; + controller.on('highlightHover', ({ highlight }) => hovers.push(highlight?.id ?? null)); + + // Page 1's content box starts at view (20, 130): both highlights are under (30, 135). + canvas.dispatchEvent(pointer('pointerdown', 30, 135)); + canvas.dispatchEvent(pointer('pointerup', 30, 135)); + await vi.waitFor(() => { + expect(taps).toHaveLength(1); + }); + // The top one, bounded over its rects on that page. + expect(taps[0]?.highlight).toEqual({ + id: 'over', + bounds: { x: 20, y: 130, width: 40, height: 38 }, + }); + + canvas.dispatchEvent(pointer('pointermove', 30, 135)); + canvas.dispatchEvent(pointer('pointermove', 32, 136)); + canvas.dispatchEvent(pointer('pointermove', 150, 140)); + canvas.dispatchEvent(pointer('pointermove', 300, 400)); + canvas.dispatchEvent(new PointerEvent('pointerleave')); + expect(hovers).toEqual(['over', 'under', null]); + + canvas.dispatchEvent(pointer('pointerdown', 300, 400)); + canvas.dispatchEvent(pointer('pointerup', 300, 400)); + await vi.waitFor(() => { + expect(taps).toHaveLength(2); + }); + expect(taps[1]?.highlight).toBeNull(); + controller.dispose(); + }); + it('moves the hovered highlight with the strip under a resting mouse', () => { + const { canvas, controller, pointer } = selectingView(); + controller.mount(document.body); + controller.scrollTo(500); + runFrames(); + controller.setHighlights([ + { id: 'a', rects: [{ pageIndex: 1, x: 0, y: 0, width: 40, height: 18 }], color: 'red' }, + ]); + const hovers: unknown[] = []; + controller.on('highlightHover', ({ highlight }) => hovers.push(highlight?.bounds.y ?? null)); + canvas.dispatchEvent(pointer('pointermove', 30, 135)); + // Ten pixels on, the highlight is ten pixels up, still under the mouse; then gone from under it. + controller.scrollTo(510); + runFrames(); + controller.scrollTo(600); + runFrames(); + expect(hovers).toEqual([130, 120, null]); + controller.dispose(); + }); + it('moves a page as the page keys do: the view less two lines', () => { + const { canvas, controller } = selectingView(); + controller.mount(document.body); + runFrames(); + expect(controller.pageLength).toBe(600 - 80); + controller.scrollBy(controller.pageLength); + runFrames(); + expect(controller.offset).toBe(520); + // Read as the view is, not as it was made. + Object.defineProperty(canvas, 'clientHeight', { value: 300 }); + expect(controller.pageLength).toBe(220); + controller.dispose(); + }); +}); diff --git a/packages/kit/tests/search-actions.test.ts b/packages/kit/tests/search-actions.test.ts index bb0d29660..07c3a7a75 100644 --- a/packages/kit/tests/search-actions.test.ts +++ b/packages/kit/tests/search-actions.test.ts @@ -117,6 +117,7 @@ describe('search engine event containment', () => { emitter, frameDriver: { markAllOverlaysDirty }, getCurrentSpread: () => 0, + isSuspended: () => false, } as unknown as Parameters[0]; const disposables = createDisposableCollection(); wireEngineEvents(deps, disposables); diff --git a/packages/kit/tests/selection-position-intent.test.ts b/packages/kit/tests/selection-position-intent.test.ts index 0417ec6ad..0f62d46c1 100644 --- a/packages/kit/tests/selection-position-intent.test.ts +++ b/packages/kit/tests/selection-position-intent.test.ts @@ -288,6 +288,7 @@ function createInternals( coordState: createCoordinatorState(), positionPersistence: createPositionPersistence(storage), restoreCompleted: false, + suspended: false, } as unknown as Internals; } diff --git a/packages/kit/tests/spread-reentrancy.test.ts b/packages/kit/tests/spread-reentrancy.test.ts index 8174768b9..46d48be3d 100644 --- a/packages/kit/tests/spread-reentrancy.test.ts +++ b/packages/kit/tests/spread-reentrancy.test.ts @@ -152,6 +152,7 @@ function createDeps( frameDriver: { markOverlayDirty }, getCurrentSpread, getRenderScale: () => 1, + isSuspended: () => false, } as unknown as WiringDeps; } diff --git a/packages/react/package.json b/packages/react/package.json index 9320ca30a..99a962db0 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -68,6 +68,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "tsdown": "^0.21.7", + "happy-dom": "^21.0.4", "@vitest/coverage-v8": "^4.1.12", "vitest": "^4.1.12" } diff --git a/packages/react/src/components.ts b/packages/react/src/components.ts index cf6a8cf2a..a75f745fb 100644 --- a/packages/react/src/components.ts +++ b/packages/react/src/components.ts @@ -1 +1 @@ -export { Reader, type ReaderProps } from './components/index'; +export { Reader, type ReaderProps, ScrollSelectionHandles } from './components/index'; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index aed7cb9b8..10e769603 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1 +1,2 @@ export { Reader, type ReaderProps } from './reader'; +export { ScrollSelectionHandles } from './scroll-selection-handles'; diff --git a/packages/react/src/components/scroll-selection-handles.tsx b/packages/react/src/components/scroll-selection-handles.tsx new file mode 100644 index 000000000..e31d018d6 --- /dev/null +++ b/packages/react/src/components/scroll-selection-handles.tsx @@ -0,0 +1,61 @@ +import { useLayoutEffect, useState, type RefObject } from 'react'; +import type { ScrollController } from '@ritojs/kit'; +import { useScrollSelectionHandles } from '../hooks/use-scroll-selection-handles'; +import type { CanvasPlacement } from './selection-handle'; +import { useSelectionHandleDrag } from './selection-handle-drag'; +import { SelectionHandlesLayer } from './selection-handles'; + +interface ScrollSelectionHandlesProps { + readonly controller: ScrollController | null; + /** The element the view's canvas is shown in, which the handles are placed in. */ + readonly rootRef: RefObject; + readonly touchInput: boolean; +} + +/** + * A scrolled view's selection handles for touch input: the page reader's + * handles, placed over the view's canvas and moved with the strip. + */ +export function ScrollSelectionHandles({ + controller, + rootRef, + touchInput, +}: ScrollSelectionHandlesProps): React.JSX.Element | null { + const handles = useScrollSelectionHandles(controller); + const visible = touchInput && handles !== null; + const placement = useScrollCanvasPlacement(rootRef, controller, visible); + const drag = useSelectionHandleDrag(controller, visible); + // A handle being dragged stays while its drag lasts. + if (!handles || !placement || (!touchInput && drag.activeVisual === null)) return null; + return ; +} + +function useScrollCanvasPlacement( + rootRef: RefObject, + controller: ScrollController | null, + enabled: boolean, +): CanvasPlacement | null { + const [placement, setPlacement] = useState(null); + useLayoutEffect(() => { + const root = rootRef.current; + if (!enabled || !root || !controller) return; + const canvas = controller.canvas; + const update = () => { + const rootRect = root.getBoundingClientRect(); + const canvasRect = canvas.getBoundingClientRect(); + const x = canvasRect.left - rootRect.left - root.clientLeft + root.scrollLeft; + const y = canvasRect.top - rootRect.top - root.clientTop + root.scrollTop; + setPlacement((current) => (current?.x === x && current.y === y ? current : { x, y })); + }; + update(); + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update); + observer?.observe(root); + observer?.observe(canvas); + window.addEventListener('resize', update); + return () => { + observer?.disconnect(); + window.removeEventListener('resize', update); + }; + }, [controller, enabled, rootRef]); + return placement; +} diff --git a/packages/react/src/components/selection-handle-drag.ts b/packages/react/src/components/selection-handle-drag.ts index a1e87b026..1cbd5d33d 100644 --- a/packages/react/src/components/selection-handle-drag.ts +++ b/packages/react/src/components/selection-handle-drag.ts @@ -27,8 +27,11 @@ interface ActiveDragSession { lastPoint: { readonly clientX: number; readonly clientY: number }; } +/** What drags a selection's end: the page controller, or a scrolled view. */ +export type SelectionHandleDragHost = Pick; + interface DragContext { - readonly controller: ReaderController | null; + readonly controller: SelectionHandleDragHost | null; readonly activeRef: RefObject; readonly setVisual: (value: ActiveDragVisual | null) => void; } @@ -48,7 +51,7 @@ interface SelectionHandleDragBindings { } export function useSelectionHandleDrag( - controller: ReaderController | null, + controller: SelectionHandleDragHost | null, enabled: boolean, ): SelectionHandleDragBindings { const activeRef = useRef(null); diff --git a/packages/react/src/components/selection-handle.tsx b/packages/react/src/components/selection-handle.tsx index 357e0e4cb..dbb730a77 100644 --- a/packages/react/src/components/selection-handle.tsx +++ b/packages/react/src/components/selection-handle.tsx @@ -22,29 +22,60 @@ interface SelectionHandleProps { } export function SelectionHandle(props: SelectionHandleProps): React.JSX.Element { - const height = Math.max(1, props.caret.height * props.scale); - const x = props.offset.x + props.caret.x * props.scale; - const caretTop = props.offset.y + props.caret.y * props.scale; - const knobY = props.edge === 'start' ? caretTop : caretTop + height; - const lineTop = props.edge === 'start' ? 22 : 22 - height; + const { knobX, knobY, line } = handleGeometry(props); return ( ); } +/** + * Where a handle's knob sits and the caret line it hangs from: the start's + * knob at the caret's block-start end, the end's at its block-end end — over + * and under a caret across a horizontal line, right and left of one across + * a vertical line (a caret wider than tall). + */ +function handleGeometry({ + edge, + caret, + offset, + scale, +}: Pick): { + knobX: number; + knobY: number; + line: CSSProperties; +} { + const x = offset.x + caret.x * scale; + const y = offset.y + caret.y * scale; + if (caret.width > caret.height) { + const width = Math.max(1, caret.width * scale); + const start = edge === 'start'; + return { + knobX: start ? x + width : x, + knobY: y, + line: lineStyle({ left: start ? 22 - width : 22, top: 21, width, height: 2 }), + }; + } + const height = Math.max(1, caret.height * scale); + return { + knobX: x, + knobY: edge === 'start' ? y : y + height, + line: lineStyle({ left: 21, top: edge === 'start' ? 22 : 22 - height, width: 2, height }), + }; +} + function handleStyle(x: number, knobY: number, active: boolean): CSSProperties { return { position: 'absolute', @@ -64,13 +95,15 @@ function handleStyle(x: number, knobY: number, active: boolean): CSSProperties { }; } -function lineStyle(top: number, height: number): CSSProperties { +function lineStyle(box: { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +}): CSSProperties { return { position: 'absolute', - left: 21, - top, - width: 2, - height, + ...box, borderRadius: 1, background: '#2563eb', pointerEvents: 'none', diff --git a/packages/react/src/components/selection-handles.tsx b/packages/react/src/components/selection-handles.tsx index 5b24c0584..309670232 100644 --- a/packages/react/src/components/selection-handles.tsx +++ b/packages/react/src/components/selection-handles.tsx @@ -26,7 +26,7 @@ export function SelectionHandles({ ); @@ -35,14 +35,16 @@ export function SelectionHandles({ interface SelectionHandlesLayerProps { readonly handles: SelectionHandleState; readonly placement: CanvasPlacement; - readonly controller: ReaderController; + /** CSS pixels of the canvas a pixel of the handles' coordinates. */ + readonly scale: number; readonly drag: ReturnType; } -function SelectionHandlesLayer({ +/** The two handles of a selection, over the canvas at `placement`. */ +export function SelectionHandlesLayer({ handles, placement, - controller, + scale, drag, }: SelectionHandlesLayerProps): React.JSX.Element { const projectedMovingCaret = handles.focusEdge ? handles[handles.focusEdge] : null; @@ -61,7 +63,7 @@ function SelectionHandlesLayer({ edge={edge} caret={caret} offset={placement} - scale={controller.renderScale} + scale={scale} active={drag.activeVisual?.edge === edge} onPointerDown={(event) => { drag.begin(edge, caret, event); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index edad7447d..114ce5c03 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -3,8 +3,17 @@ export { type UseRitoReaderOptions, type RitoReaderState, type RitoReaderActions, + useRitoScrollReader, + type UseRitoScrollReaderOptions, + type RitoScrollReaderState, + type RitoScrollReaderActions, + useRitoScrollView, + type UseRitoScrollViewOptions, + type RitoScrollViewState, + type RitoScrollViewActions, useSelection, type SelectionState, + useScrollSelectionHandles, useSearch, type SearchState, useAnnotations, diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 9a43c891b..8382b5309 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -4,7 +4,20 @@ export { type RitoReaderState, type RitoReaderActions, } from './use-rito-reader'; +export { + useRitoScrollReader, + type UseRitoScrollReaderOptions, + type RitoScrollReaderState, + type RitoScrollReaderActions, +} from './use-rito-scroll-reader'; +export { + useRitoScrollView, + type UseRitoScrollViewOptions, + type RitoScrollViewState, + type RitoScrollViewActions, +} from './use-rito-scroll-view'; export { useSelection, type SelectionState } from './use-selection'; +export { useScrollSelectionHandles } from './use-scroll-selection-handles'; export { useSearch, type SearchState } from './use-search'; export { useAnnotations, type AnnotationsState } from './use-annotations'; export { useReadingPosition, type ReadingPositionState } from './use-reading-position'; diff --git a/packages/react/src/hooks/use-annotations.ts b/packages/react/src/hooks/use-annotations.ts index 5226602d4..1e275ecc7 100644 --- a/packages/react/src/hooks/use-annotations.ts +++ b/packages/react/src/hooks/use-annotations.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { AnnotationRecord, AnnotationRecordPatch, @@ -29,10 +29,20 @@ export function useAnnotations(controller: ReaderController | null): Annotations update: (id: string, patch: AnnotationRecordPatch) => boolean; clearClicked: () => void; } { - const [annotations, setAnnotations] = useState([]); + const [annotations, setAnnotations] = useState( + () => controller?.annotations ?? [], + ); const [clickedAnnotation, setClickedAnnotation] = useState(null); const [hover, setHover] = useState(null); + // A controller starts from the records it holds: those its storage gave + // it before this hook listened are told by no event. + useEffect(() => { + setAnnotations(controller?.annotations ?? []); + setClickedAnnotation(null); + setHover(null); + }, [controller]); + useControllerEvent(controller, 'annotationsChange', ({ annotations: anns }) => { setAnnotations(anns); }); @@ -45,6 +55,21 @@ export function useAnnotations(controller: ReaderController | null): Annotations setHover(annotation ? { annotation, x, y } : null); }); + const clearClicked = useCallback(() => { + setClickedAnnotation(null); + }, []); + + return { + annotations, + clickedAnnotation, + hover, + ...useAnnotationActions(controller), + clearClicked, + }; +} + +/** What changes the controller's records. */ +function useAnnotationActions(controller: ReaderController | null) { const add = useCallback( (input: AddAnnotationInput) => controller ? controller.addAnnotation(input) : Promise.resolve(undefined), @@ -62,9 +87,5 @@ export function useAnnotations(controller: ReaderController | null): Annotations (id: string, patch: AnnotationRecordPatch) => controller?.updateAnnotation(id, patch) ?? false, [controller], ); - const clearClicked = useCallback(() => { - setClickedAnnotation(null); - }, []); - - return { annotations, clickedAnnotation, hover, add, insert, remove, update, clearClicked }; + return { add, insert, remove, update }; } diff --git a/packages/react/src/hooks/use-controller-actions.ts b/packages/react/src/hooks/use-controller-actions.ts index 77ef3794c..b142ea68b 100644 --- a/packages/react/src/hooks/use-controller-actions.ts +++ b/packages/react/src/hooks/use-controller-actions.ts @@ -1,5 +1,5 @@ import { useCallback, type RefObject } from 'react'; -import type { Reader, ReaderMargin, TocEntry } from '@ritojs/core'; +import type { Reader, ReaderMargin, ReaderSpreadMode, TocEntry } from '@ritojs/core'; import type { ReaderController, TransitionDriverOptions } from '@ritojs/kit'; type ReaderThemeOptions = Parameters[0]; @@ -11,7 +11,7 @@ export interface ControllerActions { readonly navigateToTocEntry: (entry: TocEntry) => void; readonly resize: (width: number, height: number, margin?: ReaderMargin) => void; readonly setRenderScale: (scale: number) => void; - readonly setSpreadMode: (mode: 'single' | 'double') => void; + readonly setSpreadMode: (mode: ReaderSpreadMode) => void; readonly setTheme: (opts: ReaderThemeOptions) => void; readonly setTypography: (opts: { fontSize?: number | null; @@ -37,7 +37,7 @@ export function useControllerActions( [], ), setRenderScale: useCallback((s: number) => ctrlRef.current?.setRenderScale(s), []), - setSpreadMode: useCallback((m: 'single' | 'double') => ctrlRef.current?.setSpreadMode(m), []), + setSpreadMode: useCallback((m: ReaderSpreadMode) => ctrlRef.current?.setSpreadMode(m), []), setTheme: useCallback((o: ReaderThemeOptions) => ctrlRef.current?.setTheme(o), []), setTypography: useCallback( (o: { diff --git a/packages/react/src/hooks/use-rito-reader-model.ts b/packages/react/src/hooks/use-rito-reader-model.ts index ea430a159..9260b83e4 100644 --- a/packages/react/src/hooks/use-rito-reader-model.ts +++ b/packages/react/src/hooks/use-rito-reader-model.ts @@ -4,6 +4,7 @@ import type { ReaderMargin, ReaderOptions, ReaderPublication, + ReaderSpreadMode, Spread, TocEntry, } from '@ritojs/core'; @@ -48,7 +49,7 @@ export interface RitoReaderActions { readonly navigateToTocEntry: (entry: TocEntry) => void; readonly resize: (width: number, height: number, margin?: ReaderMargin) => void; readonly setRenderScale: (scale: number) => void; - readonly setSpreadMode: (mode: 'single' | 'double') => void; + readonly setSpreadMode: (mode: ReaderSpreadMode) => void; readonly setTheme: (opts: Parameters[0]) => void; readonly setTypography: (opts: { fontSize?: number | null; diff --git a/packages/react/src/hooks/use-rito-scroll-reader.ts b/packages/react/src/hooks/use-rito-scroll-reader.ts new file mode 100644 index 000000000..b87c3f9d0 --- /dev/null +++ b/packages/react/src/hooks/use-rito-scroll-reader.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + createReader, + type PackageMetadata, + type Reader, + type ReaderLocator, + type ReaderOptions, + type ReaderPublication, + type TocEntry, +} from '@ritojs/core'; +import { + canDrawWithWebGl2, + createScrollController, + WebGlUnavailableError, + type ScrollController, + type ScrollControllerOptions, +} from '@ritojs/kit'; +import { waitForInitialReaderLayout } from './use-rito-reader-layout'; +import { followScrollView, NOTHING_READ, type ScrollViewReading } from './use-rito-scroll-state'; + +export interface UseRitoScrollReaderOptions { + /** How the book opens; it is laid out scrolled whatever `spread` says. */ + readonly reader: Omit; + readonly scroll?: ScrollControllerOptions | undefined; + /** Where the book opens: that line at the reading line once it is laid out. */ + readonly initialLocator?: ReaderLocator | null | undefined; +} + +export interface RitoScrollReaderState extends ScrollViewReading { + readonly controller: ScrollController | null; + readonly reader: Reader | null; + readonly isLoaded: boolean; + readonly isLoading: boolean; + /** Why the book could not be opened, or null. */ + readonly error: string | null; + readonly metadata: PackageMetadata | null; + readonly toc: readonly TocEntry[]; +} + +export interface RitoScrollReaderActions { + /** Opens an EPUB archive or a sharded publication. */ + readonly load: (data: ReaderPublication | PromiseLike) => Promise; + /** Lets the book go, and any load still opening one. */ + readonly unload: () => Promise; + /** The element the view is shown in, as a ref callback. */ + readonly containerRef: (element: HTMLElement | null) => void; +} + +const INITIAL: Omit = { + ...NOTHING_READ, + isLoaded: false, + isLoading: false, + error: null, + metadata: null, + toc: [], +}; + +type State = typeof INITIAL; + +interface Stack { + readonly reader: Reader; + readonly controller: ScrollController; + readonly detach: () => void; +} + +/** Owns a scrolled reader and its scroll view for one React component. */ +export function useRitoScrollReader( + options: UseRitoScrollReaderOptions, +): RitoScrollReaderState & RitoScrollReaderActions { + const optionsRef = useRef(options); + optionsRef.current = options; + const [state, setState] = useState(INITIAL); + const held = useHeldStack(); + const containerRef = useRef(null); + const load = useScrollLoad(optionsRef, held, containerRef, setState); + const { release, request } = held; + const unload = useCallback(async (): Promise => { + request.current += 1; + setState(INITIAL); + await release(); + }, [release, request]); + const attach = useCallback( + (element: HTMLElement | null): void => { + containerRef.current = element; + if (element) held.ref.current?.controller.mount(element); + }, + [held.ref], + ); + return { + ...state, + controller: held.stack?.controller ?? null, + reader: held.stack?.reader ?? null, + load, + unload, + containerRef: attach, + }; +} + +interface HeldStack { + readonly stack: Stack | null; + readonly ref: { current: Stack | null }; + readonly hold: (stack: Stack) => void; + /** Lets the held stack go; a load counted after it supersedes any still opening. */ + readonly release: () => Promise; + readonly request: { current: number }; +} + +/** The stack shown, let go when another replaces it and when the component unmounts. */ +function useHeldStack(): HeldStack { + const [stack, setStack] = useState(null); + const ref = useRef(null); + const request = useRef(0); + const release = useCallback(async (): Promise => { + const current = ref.current; + ref.current = null; + setStack(null); + if (current) await disposeStack(current); + }, []); + const hold = useCallback((next: Stack): void => { + ref.current = next; + setStack(next); + }, []); + useEffect( + () => () => { + request.current += 1; + void release(); + }, + [release], + ); + return { stack, ref, hold, release, request }; +} + +function useScrollLoad( + optionsRef: { readonly current: UseRitoScrollReaderOptions }, + held: HeldStack, + containerRef: { readonly current: HTMLElement | null }, + setState: (state: State | ((previous: State) => State)) => void, +): RitoScrollReaderActions['load'] { + const { hold, release, request: requestRef } = held; + return useCallback( + async (data: ReaderPublication | PromiseLike): Promise => { + const request = ++requestRef.current; + await release(); + setState({ ...INITIAL, isLoading: true }); + try { + const options = optionsRef.current; + const opened = await openStack(await data, options, setState, requestRef, request); + if (opened === null) return; + hold(opened); + if (containerRef.current) opened.controller.mount(containerRef.current); + if (options.initialLocator) await opened.controller.scrollToLocator(options.initialLocator); + setState((previous) => ({ + ...previous, + isLoaded: true, + isLoading: false, + metadata: opened.reader.metadata, + toc: opened.reader.toc, + })); + } catch (error: unknown) { + if (request !== requestRef.current) return; + setState({ ...INITIAL, error: error instanceof Error ? error.message : String(error) }); + } + }, + [containerRef, hold, optionsRef, release, requestRef, setState], + ); +} + +/** The reader laid out scrolled and its view, or null when a newer load replaced this one. */ +async function openStack( + data: ReaderPublication, + options: UseRitoScrollReaderOptions, + setState: (update: (previous: State) => State) => void, + requestRef: { readonly current: number }, + request: number, +): Promise { + // Said before the book is opened, not after it was laid out for nothing. + if (!canDrawWithWebGl2()) throw new WebGlUnavailableError(); + const canvas = document.createElement('canvas'); + const reader = await createReader(data, canvas, { ...options.reader, spread: 'scrolled' }); + const stale = (): boolean => request !== requestRef.current; + try { + if (!stale()) await waitForInitialReaderLayout(reader, request, requestRef); + } catch (error: unknown) { + await reader.dispose(); + throw error; + } + if (stale()) { + await reader.dispose(); + return null; + } + const controller = createScrollController(reader, canvas, options.scroll); + const detach = followScrollView(controller, setState); + return { reader, controller, detach }; +} + +async function disposeStack(stack: Stack): Promise { + try { + stack.detach(); + stack.controller.dispose(); + } catch { + // The reader is released even when the view's cleanup fails. + } + try { + await stack.reader.dispose(); + } catch { + // A rejected release must not block a later load. + } +} diff --git a/packages/react/src/hooks/use-rito-scroll-state.ts b/packages/react/src/hooks/use-rito-scroll-state.ts new file mode 100644 index 000000000..0a17f7242 --- /dev/null +++ b/packages/react/src/hooks/use-rito-scroll-state.ts @@ -0,0 +1,37 @@ +import type { ReaderLocator } from '@ritojs/core'; +import type { ScrollController, ScrollControllerEvents } from '@ritojs/kit'; + +/** What a scroll view says as it is read: how far through, where, and what is selected. */ +export interface ScrollViewReading { + /** How far through the book the strip is, from 0 to 1. */ + readonly progress: number; + /** Where the reader reads, as of the strip's last rest. */ + readonly position: ReaderLocator | null; + /** What is selected, its text and its rects where the view shows them; null for nothing. */ + readonly selection: ScrollControllerEvents['selection'] | null; +} + +export const NOTHING_READ: ScrollViewReading = { progress: 0, position: null, selection: null }; + +/** The view's events into a hook's state. */ +export function followScrollView( + controller: ScrollController, + setState: (update: (previous: S) => S) => void, +): () => void { + const stops = [ + controller.on('scroll', ({ progress }) => { + setState((previous) => + previous.progress === progress ? previous : { ...previous, progress }, + ); + }), + controller.on('position', ({ locator }) => { + setState((previous) => ({ ...previous, position: locator })); + }), + controller.on('selection', (selection) => { + setState((previous) => ({ ...previous, selection: selection.range ? selection : null })); + }), + ]; + return () => { + for (const stop of stops) stop(); + }; +} diff --git a/packages/react/src/hooks/use-rito-scroll-view.ts b/packages/react/src/hooks/use-rito-scroll-view.ts new file mode 100644 index 000000000..de22d454c --- /dev/null +++ b/packages/react/src/hooks/use-rito-scroll-view.ts @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Reader, ReaderLocator } from '@ritojs/core'; +import { + createScrollController, + type ReaderController, + type ScrollController, + type ScrollControllerOptions, +} from '@ritojs/kit'; +import { followScrollView, NOTHING_READ, type ScrollViewReading } from './use-rito-scroll-state'; + +export interface UseRitoScrollViewOptions { + /** Whether the book is read scrolled now. */ + readonly active: boolean; + /** How the book is paged again once it is no longer read scrolled. */ + readonly paged: 'single' | 'double'; + readonly scroll?: ScrollControllerOptions | undefined; +} + +export interface RitoScrollViewState extends ScrollViewReading { + /** The scroll view while the book is read scrolled and shown; null otherwise. */ + readonly controller: ScrollController | null; + /** Whether the book is being laid out scrolled, shown once it is. */ + readonly isLoading: boolean; + /** Why the book could not be laid out scrolled, or null. */ + readonly error: string | null; +} + +export interface RitoScrollViewActions { + /** The element the view is shown in, as a ref callback. */ + readonly containerRef: (element: HTMLElement | null) => void; +} + +const LAYOUT_TIMEOUT_MS = 15_000; +const LOADING_INDICATOR_DELAY_MS = 120; +const INITIAL: Omit = { + ...NOTHING_READ, + isLoading: false, + error: null, +}; +type State = typeof INITIAL; + +/** One turn of the book to scrolled reading and back, as one effect runs it. */ +interface Switch { + /** Until the effect is cleaned up: the view is wanted. */ + live: boolean; + /** Whether the pages handed the reader over, so that leaving gives it back. */ + suspended: boolean; + view: { readonly controller: ScrollController; readonly detach: () => void } | null; + /** Where the view reads, and the pages open again. */ + reading: ReaderLocator | null; +} + +/** + * The book a page controller shows, read scrolled: while `active`, the + * pages hand their reader over (`ReaderController.suspend`), the reader is + * laid out scrolled and a scroll view shows it from where the pages were. + * Turned off, the reader is paged again from where the view was read and + * the pages take it back. One reader throughout: nothing is opened again. + */ +export function useRitoScrollView( + pages: ReaderController | null, + options: UseRitoScrollViewOptions, +): RitoScrollViewState & RitoScrollViewActions { + const optionsRef = useRef(options); + optionsRef.current = options; + // The pages of the latest render: a cleanup that finds others there was + // run because its pages were replaced, and their owner lets them go. + const pagesRef = useRef(pages); + pagesRef.current = pages; + const [state, setState] = useState(INITIAL); + const [shown, setShown] = useState(null); + const containerRef = useRef(null); + // Switches run one after another: a view is let go before the next one is made. + const queue = useRef(Promise.resolve()); + useEffect(() => { + if (!pages || !options.active) return; + const run: Switch = { live: true, suspended: false, view: null, reading: null }; + const show = (controller: ScrollController): void => { + if (containerRef.current) controller.mount(containerRef.current); + setShown(controller); + }; + queue.current = queue.current.then(() => enter(pages, run, optionsRef, setState, show)); + return () => { + run.live = false; + const replaced = pagesRef.current !== pages; + setShown(null); + setState(INITIAL); + queue.current = queue.current.then(() => + leave(pages, run, replaced ? null : optionsRef.current.paged), + ); + }; + }, [pages, options.active]); + const attach = useCallback( + (element: HTMLElement | null): void => { + containerRef.current = element; + if (element) shown?.mount(element); + }, + [shown], + ); + return { ...state, controller: shown, containerRef: attach }; +} + +async function enter( + pages: ReaderController, + run: Switch, + optionsRef: { readonly current: UseRitoScrollViewOptions }, + setState: (update: State | ((previous: State) => State)) => void, + show: (controller: ScrollController) => void, +): Promise { + // Read again after each wait: the effect may have been cleaned up meanwhile. + const over = (): boolean => !run.live; + if (over()) return; + run.reading = pages.position?.sourceLocator ?? null; + pages.suspend(); + run.suspended = true; + const reader = pages.reader; + const indicator = setTimeout(() => { + if (!over()) setState((previous) => ({ ...previous, isLoading: true })); + }, LOADING_INDICATOR_DELAY_MS); + try { + if (!isScrolled(reader)) reader.setSpreadMode('scrolled'); + await layoutWhere(reader, () => isScrolled(reader)); + if (over()) return; + const controller = createScrollController(reader, document.createElement('canvas'), { + ...optionsRef.current.scroll, + }); + const stopReading = controller.on('position', ({ locator }) => { + run.reading = locator; + }); + const stopFollowing = followScrollView(controller, setState); + run.view = { + controller, + detach: () => { + stopReading(); + stopFollowing(); + }, + }; + if (run.reading) await controller.scrollToLocator(run.reading); + if (over()) return; + setState((previous) => ({ ...previous, isLoading: false, error: null })); + show(controller); + } catch (error: unknown) { + if (!over()) setState({ ...INITIAL, error: messageOf(error) }); + } finally { + clearTimeout(indicator); + } +} + +/** Lets the view go and, unless the pages are going too (`paged` null), pages the reader again. */ +async function leave( + pages: ReaderController, + run: Switch, + paged: 'single' | 'double' | null, +): Promise { + if (run.view) { + run.view.detach(); + run.view.controller.dispose(); + } + // Pages let go meanwhile have nothing to take back. + if (!run.suspended || paged === null || !pages.suspended) return; + const reader = pages.reader; + try { + // The pages open on the line the view was read at. + if (run.reading && isScrolled(reader)) await reader.navigateToLocator?.(run.reading); + if (isScrolled(reader)) { + reader.setSpreadMode(paged); + await layoutWhere(reader, () => !isScrolled(reader)); + } + } catch { + // A reader let go meanwhile, or one that could not be paged again: the + // pages take back what there is. + } finally { + pages.resume(); + } +} + +/** Whether the reader's committed layout is scrolled. */ +function isScrolled(reader: Reader): boolean { + return reader.getScrollLayout() !== undefined; +} + +/** Resolves once the reader commits a layout `holds` accepts, at once when it already has. */ +function layoutWhere(reader: Reader, holds: () => boolean): Promise { + if (holds()) return Promise.resolve(); + return new Promise((resolve, reject) => { + let stop = (): void => undefined; + const timeout = setTimeout(() => { + stop(); + reject(new Error('The reader was not laid out in time')); + }, LAYOUT_TIMEOUT_MS); + stop = reader.onLayoutCommitted(() => { + if (!holds()) return; + clearTimeout(timeout); + stop(); + resolve(); + }); + }); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/react/src/hooks/use-scroll-selection-handles.ts b/packages/react/src/hooks/use-scroll-selection-handles.ts new file mode 100644 index 000000000..52c7ab3ef --- /dev/null +++ b/packages/react/src/hooks/use-scroll-selection-handles.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import type { ScrollController, SelectionHandleState } from '@ritojs/kit'; + +/** + * A scrolled view's selection ends where the view shows them, as they are + * selected and as the strip moves under them; null while nothing is + * selected or a gesture is still selecting. `focusEdge` says which end is + * the focus, where a toolbar for the selection goes. + */ +export function useScrollSelectionHandles( + controller: ScrollController | null, +): SelectionHandleState | null { + const [handles, setHandles] = useState(null); + useEffect(() => { + setHandles(controller?.selectionHandles() ?? null); + if (!controller) return; + const stops = [ + controller.on('selection', (selection) => { + setHandles(selection.range && !selection.selecting ? selection.handles : null); + }), + controller.on('scroll', () => { + if (controller.selection) setHandles(controller.selectionHandles()); + }), + ]; + return () => { + for (const stop of stops) stop(); + }; + }, [controller]); + return handles; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 423d8a3a8..d46ce88be 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,8 +4,17 @@ export { type UseRitoReaderOptions, type RitoReaderState, type RitoReaderActions, + useRitoScrollReader, + type UseRitoScrollReaderOptions, + type RitoScrollReaderState, + type RitoScrollReaderActions, + useRitoScrollView, + type UseRitoScrollViewOptions, + type RitoScrollViewState, + type RitoScrollViewActions, useSelection, type SelectionState, + useScrollSelectionHandles, useSearch, type SearchState, useAnnotations, @@ -23,4 +32,4 @@ export { } from './hooks/index'; // Components -export { Reader, type ReaderProps } from './components/index'; +export { Reader, type ReaderProps, ScrollSelectionHandles } from './components/index'; diff --git a/packages/react/tests/scroll-selection-handles.test.tsx b/packages/react/tests/scroll-selection-handles.test.tsx new file mode 100644 index 000000000..be3c66c6e --- /dev/null +++ b/packages/react/tests/scroll-selection-handles.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom + +import { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ScrollController, SelectionHandleDrag, SelectionHandleState } from '@ritojs/kit'; +import { ScrollSelectionHandles } from '../src/components/scroll-selection-handles'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Listener = (data: unknown) => void; + +const HANDLES: SelectionHandleState = { + start: { x: 30, y: 50, width: 0, height: 18 }, + end: { x: 120, y: 130, width: 0, height: 18 }, + focusEdge: 'end', +}; + +/** A scrolled view whose events the test raises. */ +function scrollStub() { + const listeners = new Map(); + const canvas = document.createElement('canvas'); + let handles: SelectionHandleState | null = null; + const drag: SelectionHandleDrag = { update: vi.fn(), finish: vi.fn(), cancel: vi.fn() }; + const controller = { + canvas, + get selection() { + return handles ? { selectedText: 'words' } : null; + }, + selectionHandles: () => handles, + beginSelectionHandleDrag: vi.fn(() => drag), + on(event: string, listener: Listener) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return () => undefined; + }, + }; + const emit = (event: string, data: unknown) => { + for (const listener of listeners.get(event) ?? []) listener(data); + }; + return { + controller: controller as unknown as ScrollController, + drag, + canvas, + select(next: SelectionHandleState | null) { + handles = next; + emit('selection', { + range: next ? {} : null, + text: next ? 'words' : '', + selecting: false, + rects: [], + handles: next, + }); + }, + scrolled(next: SelectionHandleState) { + handles = next; + emit('scroll', { offset: 10, extent: 1000, viewport: 600, progress: 0.01 }); + }, + begin: controller.beginSelectionHandleDrag, + }; +} + +function View({ controller }: { controller: ScrollController }) { + const root = useRef(null); + return ( +
+ {/* The view's canvas, which the controller put here. */} + { + node?.parentElement?.appendChild(controller.canvas); + }} + /> + +
+ ); +} + +describe('scrolled selection handles', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('shows the ends of a selection and follows them as the strip scrolls', () => { + const stub = scrollStub(); + act(() => { + root.render(); + }); + expect(container.querySelector('[data-rito-selection-handle]')).toBeNull(); + act(() => { + stub.select(HANDLES); + }); + const end = () => container.querySelector('[data-rito-selection-handle="end"]'); + expect(container.querySelector('[data-rito-selection-handle="start"]')).not.toBeNull(); + // The end's knob sits below its caret: 130 + 18 down, less the knob's 22. + expect(end()?.style.top).toBe('126px'); + act(() => { + stub.scrolled({ ...HANDLES, end: { x: 120, y: 90, width: 0, height: 18 } }); + }); + expect(end()?.style.top).toBe('86px'); + act(() => { + stub.select(null); + }); + expect(container.querySelector('[data-rito-selection-handle]')).toBeNull(); + }); + + it('hangs the knobs off the right and left ends of carets across a vertical line', () => { + const stub = scrollStub(); + act(() => { + root.render(); + }); + act(() => { + stub.select({ + start: { x: 300, y: 40, width: 23, height: 0 }, + end: { x: 254, y: 120, width: 23, height: 0 }, + focusEdge: 'end', + }); + }); + const handle = (edge: string) => + container.querySelector(`[data-rito-selection-handle="${edge}"]`); + // The start's knob at its caret's right end, the end's at its left end, + // each at the caret's height, less the knob's 22. + expect([handle('start')?.style.left, handle('start')?.style.top]).toEqual(['301px', '18px']); + expect([handle('end')?.style.left, handle('end')?.style.top]).toEqual(['232px', '98px']); + }); + + it('drags an end through the view', () => { + const stub = scrollStub(); + act(() => { + root.render(); + }); + act(() => { + stub.select(HANDLES); + }); + const end = container.querySelector('[data-rito-selection-handle="end"]'); + expect(end).not.toBeNull(); + act(() => { + end?.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + pointerType: 'touch', + pointerId: 1, + clientX: 120, + clientY: 148, + }), + ); + }); + expect(stub.begin).toHaveBeenCalledWith('end', { clientX: 120, clientY: 148 }); + }); +}); diff --git a/packages/react/tests/use-annotations.test.tsx b/packages/react/tests/use-annotations.test.tsx new file mode 100644 index 000000000..85f1b4cdb --- /dev/null +++ b/packages/react/tests/use-annotations.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { AnnotationRecord, ReaderController } from '@ritojs/kit'; +import { useAnnotations } from '../src/hooks'; + +type Listener = (data: unknown) => void; + +/** A controller holding `records`, whose events the test raises. */ +function fakeController(records: readonly AnnotationRecord[]) { + const listeners = new Map(); + const controller = { + annotations: records, + on(event: string, listener: Listener) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return () => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((each) => each !== listener), + ); + }; + }, + emit(event: string, data: unknown) { + for (const listener of listeners.get(event) ?? []) listener(data); + }, + }; + return controller; +} + +function record(id: string): AnnotationRecord { + return { id, kind: 'highlight', createdAt: 1 } as unknown as AnnotationRecord; +} + +let root: Root; +let host: HTMLElement; +let value: ReturnType | null; + +function Harness({ controller }: { controller: ReaderController | null }) { + value = useAnnotations(controller); + return null; +} + +beforeEach(() => { + value = null; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + host.remove(); +}); + +describe('useAnnotations', () => { + it('starts from the records a controller holds, as its storage gave them before any event', () => { + const first = fakeController([record('a'), record('b')]); + act(() => { + root.render(); + }); + expect(value?.annotations.map((each) => each.id)).toEqual(['a', 'b']); + + // Another book's controller, its records already loaded. + const second = fakeController([record('c')]); + act(() => { + root.render(); + }); + expect(value?.annotations.map((each) => each.id)).toEqual(['c']); + + act(() => { + second.emit('annotationsChange', { annotations: [record('c'), record('d')] }); + }); + expect(value?.annotations.map((each) => each.id)).toEqual(['c', 'd']); + + act(() => { + root.render(); + }); + expect(value?.annotations).toEqual([]); + }); +}); diff --git a/packages/react/tests/use-rito-scroll-reader.test.tsx b/packages/react/tests/use-rito-scroll-reader.test.tsx new file mode 100644 index 000000000..5e756c6ff --- /dev/null +++ b/packages/react/tests/use-rito-scroll-reader.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment happy-dom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useRitoScrollReader, type UseRitoScrollReaderOptions } from '../src/hooks'; + +const { createReaderMock, createScrollControllerMock } = vi.hoisted(() => ({ + createReaderMock: vi.fn(), + createScrollControllerMock: vi.fn(), +})); + +vi.mock('@ritojs/core', () => ({ + createReader: createReaderMock, +})); + +vi.mock('@ritojs/kit', () => ({ + createScrollController: createScrollControllerMock, + canDrawWithWebGl2: () => true, + WebGlUnavailableError: class WebGlUnavailableError extends Error {}, +})); + +type Listener = (data: unknown) => void; + +/** A scroll view whose events the test raises. */ +function fakeController() { + const listeners = new Map(); + return { + mount: vi.fn(), + dispose: vi.fn(), + scrollToLocator: vi.fn(() => Promise.resolve(true)), + on(event: string, listener: Listener) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return () => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((each) => each !== listener), + ); + }; + }, + emit(event: string, data: unknown) { + for (const listener of listeners.get(event) ?? []) listener(data); + }, + }; +} + +function fakeReader(title: string) { + return { + totalSpreads: 3, + metadata: { title }, + toc: [{ label: 'One', href: 'one.xhtml', children: [] }], + onLayoutCommitted: () => () => undefined, + dispose: vi.fn(() => Promise.resolve()), + }; +} + +type HookValue = ReturnType; + +let root: Root; +let host: HTMLElement; +let value: HookValue | null; + +function Harness({ options }: { options: UseRitoScrollReaderOptions }) { + const hook = useRitoScrollReader(options); + value = hook; + return
; +} + +function current(): HookValue { + if (value === null) throw new Error('the hook rendered'); + return value; +} + +beforeEach(() => { + value = null; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + createReaderMock.mockReset(); + createScrollControllerMock.mockReset(); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + host.remove(); +}); + +const OPTIONS: UseRitoScrollReaderOptions = { + reader: { width: 400, height: 600, spread: undefined } as UseRitoScrollReaderOptions['reader'], + initialLocator: { href: 'two.xhtml' }, +}; + +describe('useRitoScrollReader', () => { + it('opens the book scrolled, shows its view in the container and follows it', async () => { + const reader = fakeReader('A book'); + const controller = fakeController(); + createReaderMock.mockResolvedValue(reader); + createScrollControllerMock.mockReturnValue(controller); + act(() => { + root.render(); + }); + await act(async () => { + await current().load(new ArrayBuffer(1)); + }); + expect(createReaderMock.mock.calls[0]?.[2]).toMatchObject({ width: 400, spread: 'scrolled' }); + expect(controller.mount).toHaveBeenCalledWith(host.firstElementChild); + expect(controller.scrollToLocator).toHaveBeenCalledWith({ href: 'two.xhtml' }); + expect(current()).toMatchObject({ + isLoaded: true, + isLoading: false, + error: null, + metadata: { title: 'A book' }, + progress: 0, + position: null, + selection: null, + }); + expect(current().controller).toBe(controller); + + act(() => { + controller.emit('scroll', { offset: 300, extent: 1_200, viewport: 600, progress: 0.5 }); + controller.emit('position', { locator: { href: 'two.xhtml', progression: 0.4 } }); + controller.emit('selection', { + range: { selectedText: 'words' }, + text: 'words', + selecting: false, + rects: [{ x: 1, y: 2, width: 3, height: 4 }], + }); + }); + expect(current().progress).toBe(0.5); + expect(current().position).toEqual({ href: 'two.xhtml', progression: 0.4 }); + expect(current().selection?.text).toBe('words'); + act(() => { + controller.emit('selection', { range: null, text: '', selecting: false, rects: [] }); + }); + expect(current().selection).toBeNull(); + }); + + it('lets a book go when another opens and when it unmounts', async () => { + const first = { reader: fakeReader('First'), controller: fakeController() }; + const second = { reader: fakeReader('Second'), controller: fakeController() }; + createReaderMock.mockResolvedValueOnce(first.reader).mockResolvedValueOnce(second.reader); + createScrollControllerMock + .mockReturnValueOnce(first.controller) + .mockReturnValueOnce(second.controller); + act(() => { + root.render(); + }); + await act(async () => { + await current().load(new ArrayBuffer(1)); + }); + await act(async () => { + await current().load(new ArrayBuffer(2)); + }); + expect(first.controller.dispose).toHaveBeenCalled(); + expect(first.reader.dispose).toHaveBeenCalled(); + expect(current().metadata).toEqual({ title: 'Second' }); + act(() => { + root.unmount(); + }); + await vi.waitFor(() => { + expect(second.reader.dispose).toHaveBeenCalled(); + }); + expect(second.controller.dispose).toHaveBeenCalled(); + root = createRoot(host); + }); + + it('lets the book go when asked', async () => { + const reader = fakeReader('A book'); + const controller = fakeController(); + createReaderMock.mockResolvedValue(reader); + createScrollControllerMock.mockReturnValue(controller); + act(() => { + root.render(); + }); + await act(async () => { + await current().load(new ArrayBuffer(1)); + }); + await act(async () => { + await current().unload(); + }); + expect(controller.dispose).toHaveBeenCalled(); + expect(reader.dispose).toHaveBeenCalled(); + expect(current()).toMatchObject({ isLoaded: false, controller: null, metadata: null }); + }); + + it('says why a book could not be opened', async () => { + createReaderMock.mockRejectedValue(new Error('not an EPUB')); + act(() => { + root.render(); + }); + await act(async () => { + await current().load(new ArrayBuffer(1)); + }); + expect(current()).toMatchObject({ isLoaded: false, isLoading: false, error: 'not an EPUB' }); + }); +}); diff --git a/packages/react/tests/use-rito-scroll-view.test.tsx b/packages/react/tests/use-rito-scroll-view.test.tsx new file mode 100644 index 000000000..5dbb4b21c --- /dev/null +++ b/packages/react/tests/use-rito-scroll-view.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReaderController } from '@ritojs/kit'; +import { useRitoScrollView, type UseRitoScrollViewOptions } from '../src/hooks'; + +const { createScrollControllerMock } = vi.hoisted(() => ({ + createScrollControllerMock: vi.fn(), +})); + +vi.mock('@ritojs/kit', () => ({ + createScrollController: createScrollControllerMock, +})); + +type Listener = (data: unknown) => void; + +/** A scroll view whose events the test raises. */ +function fakeView() { + const listeners = new Map(); + return { + mount: vi.fn(), + dispose: vi.fn(), + scrollToLocator: vi.fn(() => Promise.resolve(true)), + on(event: string, listener: Listener) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return () => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((each) => each !== listener), + ); + }; + }, + emit(event: string, data: unknown) { + for (const listener of listeners.get(event) ?? []) listener(data); + }, + }; +} + +/** Pages over a reader whose layouts commit when the test says. */ +function fakePages() { + const committed = new Set<() => void>(); + let scrolled = false; + const reader = { + getScrollLayout: () => + scrolled ? { axis: 'down', windowLength: 600, chapters: [] } : undefined, + setSpreadMode: vi.fn(() => true), + navigateToLocator: vi.fn(() => Promise.resolve({ status: 'resolved' })), + onLayoutCommitted: (listener: () => void) => { + committed.add(listener); + return () => committed.delete(listener); + }, + }; + const pages = { + reader, + position: { sourceLocator: { href: 'two.xhtml' } }, + suspended: false, + suspend: vi.fn(() => { + pages.suspended = true; + }), + resume: vi.fn(() => { + pages.suspended = false; + }), + }; + return { + pages: pages as unknown as ReaderController, + reader, + suspend: pages.suspend, + resume: pages.resume, + /** The reader commits a layout, scrolled or not. */ + commit(isScrolled: boolean) { + scrolled = isScrolled; + for (const listener of [...committed]) listener(); + }, + }; +} + +type HookValue = ReturnType; + +let root: Root; +let host: HTMLElement; +let value: HookValue | null; + +function Harness(props: { pages: ReaderController | null; options: UseRitoScrollViewOptions }) { + const hook = useRitoScrollView(props.pages, props.options); + value = hook; + return
; +} + +function current(): HookValue { + if (value === null) throw new Error('the hook rendered'); + return value; +} + +/** Lets the hook's queued switches run. */ +async function settle(): Promise { + await act(async () => { + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve(); + }); +} + +beforeEach(() => { + value = null; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + createScrollControllerMock.mockReset(); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + host.remove(); +}); + +describe('useRitoScrollView', () => { + it('takes the pages reader, lays it out scrolled and shows it from where the pages were', async () => { + const fixture = fakePages(); + const view = fakeView(); + createScrollControllerMock.mockReturnValue(view); + act(() => { + root.render(); + }); + await settle(); + expect(fixture.suspend).toHaveBeenCalledOnce(); + expect(fixture.reader.setSpreadMode).toHaveBeenCalledWith('scrolled'); + // Nothing is made until the scrolled layout commits. + expect(createScrollControllerMock).not.toHaveBeenCalled(); + expect(current().controller).toBeNull(); + + fixture.commit(true); + await settle(); + expect(createScrollControllerMock.mock.calls[0]?.[0]).toBe(fixture.reader); + expect(view.scrollToLocator).toHaveBeenCalledWith({ href: 'two.xhtml' }); + expect(view.mount).toHaveBeenCalledWith(host.firstElementChild); + expect(current().controller).toBe(view); + expect(current().isLoading).toBe(false); + }); + + it('pages the reader again on the line the view was read at, then gives it back to the pages', async () => { + const fixture = fakePages(); + const view = fakeView(); + createScrollControllerMock.mockReturnValue(view); + act(() => { + root.render(); + }); + await settle(); + fixture.commit(true); + await settle(); + act(() => { + view.emit('position', { locator: { href: 'three.xhtml' } }); + }); + + act(() => { + root.render(); + }); + await settle(); + expect(view.dispose).toHaveBeenCalledOnce(); + expect(fixture.reader.navigateToLocator).toHaveBeenCalledWith({ href: 'three.xhtml' }); + expect(fixture.reader.setSpreadMode).toHaveBeenLastCalledWith('single'); + // The pages wait for the paged layout. + expect(fixture.resume).not.toHaveBeenCalled(); + + fixture.commit(false); + await settle(); + expect(fixture.resume).toHaveBeenCalledOnce(); + expect(current().controller).toBeNull(); + }); + + it('only lets the view go when the pages it took the reader from are replaced', async () => { + const fixture = fakePages(); + const view = fakeView(); + createScrollControllerMock.mockReturnValue(view); + act(() => { + root.render(); + }); + await settle(); + fixture.commit(true); + await settle(); + + act(() => { + root.render(); + }); + await settle(); + expect(view.dispose).toHaveBeenCalledOnce(); + expect(fixture.reader.navigateToLocator).not.toHaveBeenCalled(); + expect(fixture.resume).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rito-core-wasm/src/reader-session-artifact-decoder-runtime.js b/packages/rito-core-wasm/src/reader-session-artifact-decoder-runtime.js index 6990d09b2..f24681a7b 100644 --- a/packages/rito-core-wasm/src/reader-session-artifact-decoder-runtime.js +++ b/packages/rito-core-wasm/src/reader-session-artifact-decoder-runtime.js @@ -51,6 +51,9 @@ export function decodeRitoReaderArtifact(value) { height: reader.f64('artifact height'), bookPageIndex: reader.option('book page index', () => reader.u32('book page index')), bookPageCount: reader.option('book page count', () => reader.u32('book page count')), + targetBlockOffset: reader.option('target block offset', () => + reader.f64('target block offset'), + ), navigation: { previous: readTaggedU32(reader, 'adjacent availability', ADJACENT), next: readTaggedU32(reader, 'adjacent availability', ADJACENT), @@ -170,11 +173,24 @@ function readFont(reader) { weight: record.u16('font weight'), shapeFingerprint: record.string('font shape fingerprint'), byteLength: record.u64('font byte length'), + features: readFontFeatures(record), }; record.finish('font'); return font; } +function readFontFeatures(record) { + const count = record.count('font features'); + const features = []; + for (let index = 0; index < count; index += 1) { + features.push({ + tag: record.string('font feature tag'), + value: record.u32('font feature value'), + }); + } + return features; +} + function readPage(reader) { const record = reader.record('page'); const page = { diff --git a/packages/rito-core-wasm/src/reader-session-query-decoder-runtime.js b/packages/rito-core-wasm/src/reader-session-query-decoder-runtime.js index 25d06dc34..0e373e475 100644 --- a/packages/rito-core-wasm/src/reader-session-query-decoder-runtime.js +++ b/packages/rito-core-wasm/src/reader-session-query-decoder-runtime.js @@ -13,6 +13,7 @@ const FONT_SOURCES = ['host', 'publication']; const FONT_GENERIC_ROLES = [null, 'serif', 'sansSerif', 'monospace']; const EXACT_STATUSES = ['resolved', 'pending', 'unavailable']; const AFFINITIES = ['upstream', 'downstream']; +const WRITING_MODES = ['horizontal-tb', 'vertical-rl']; const BOUNDARIES = ['start', 'end']; const UNAVAILABLE_REASONS = [ 'shape-unavailable', @@ -33,6 +34,7 @@ const ANNOTATION_LEVELS = [ ]; const LOCATOR_MATCHES = ['source-range', 'source-point', 'anchor', 'progression', 'href']; const ORDERS = [-1, 0, 1]; +const SCROLL_AXES = ['down', 'left']; /** Decodes a `RITOFNT1` font catalog: every family a `font-family` list can name. */ export function decodeRitoReaderFontCatalog(value) { @@ -257,8 +259,10 @@ function readCaret(reader) { geometry: record.option('caret geometry', () => ({ x: record.f64('caret x'), y: record.f64('caret y'), + width: record.f64('caret width'), height: record.f64('caret height'), })), + writingMode: u8Tag(record, 'caret writing mode', WRITING_MODES), href: record.string('caret href'), sourcePoint: readSourcePoint(record), }; @@ -321,6 +325,9 @@ function readLocation(reader) { pageIndex: reader.u32('located page'), drawn: reader.bool('page drawn'), matchedBy: u8Tag(reader, 'locator match', LOCATOR_MATCHES), + blockOffset: reader.option('located block offset', () => + reader.f64('located block offset'), + ), }; case 1: return { kind: 'not-laid-out' }; @@ -343,6 +350,7 @@ function readRevisionLayout(reader) { spineIndex: reader.u32('chapter spine index'), startPage: reader.u32('chapter start page'), endPage: reader.u32('chapter end page'), + extent: reader.option('chapter extent', () => reader.f64('chapter extent')), })), tocTargets: readCollection(reader, 'layout toc targets', () => ({ tocId: reader.u32('toc target entry'), @@ -352,6 +360,7 @@ function readRevisionLayout(reader) { optionalU32('page toc entry'), ), complete: reader.bool('layout complete'), + scrollAxis: reader.option('scroll axis', () => u8Tag(reader, 'scroll axis', SCROLL_AXES)), }; } diff --git a/packages/rito-core-wasm/src/reader-session-request-runtime.js b/packages/rito-core-wasm/src/reader-session-request-runtime.js index 747098d9e..d8383b46f 100644 --- a/packages/rito-core-wasm/src/reader-session-request-runtime.js +++ b/packages/rito-core-wasm/src/reader-session-request-runtime.js @@ -1,5 +1,7 @@ import { ReaderWireWriter } from './reader-session-wire-base-runtime.js'; +const SPREAD_MODES = ['single', 'double', 'scrolled']; + export function encodeRitoReaderArtifactRequest(request) { const writer = ReaderWireWriter.message('RITOREQ1'); writer.externalId(request.sessionId, 'session id'); @@ -40,7 +42,7 @@ function writeLayout(writer, value) { writer.f64(value.marginRight, 'right margin'); writer.f64(value.marginBottom, 'bottom margin'); writer.f64(value.marginLeft, 'left margin'); - writer.u32(value.spreadMode === 'single' ? 0 : requireDouble(value.spreadMode), 'spread mode'); + writer.u32(spreadMode(value.spreadMode), 'spread mode'); writer.bool(value.firstPageAlone); writer.f64(value.spreadGap, 'spread gap'); writer.f64(value.rootFontSize, 'root font size'); @@ -87,9 +89,10 @@ export function writeRitoReaderSourcePoint(writer, value) { }); } -function requireDouble(value) { - if (value !== 'double') throw new RangeError(`unknown spread mode: ${String(value)}`); - return 1; +function spreadMode(value) { + const tag = SPREAD_MODES.indexOf(value); + if (tag < 0) throw new RangeError(`unknown spread mode: ${String(value)}`); + return tag; } function textProfile(value) { diff --git a/packages/rito-core-wasm/src/reader-session-wire-base-runtime.js b/packages/rito-core-wasm/src/reader-session-wire-base-runtime.js index 5e3339823..1a7b93456 100644 --- a/packages/rito-core-wasm/src/reader-session-wire-base-runtime.js +++ b/packages/rito-core-wasm/src/reader-session-wire-base-runtime.js @@ -5,7 +5,7 @@ * against this one constant — the Rust side has a single constant too, * so scattering literals here is how the two drift apart unnoticed. */ -export const READER_PROTOCOL_VERSION = 7; +export const READER_PROTOCOL_VERSION = 9; const MAX_WIRE_BYTES = 256 * 1024 * 1024; const MAX_STRING_BYTES = 16 * 1024 * 1024; diff --git a/packages/rito-core-wasm/src/reader-session-worker-query-runtime.js b/packages/rito-core-wasm/src/reader-session-worker-query-runtime.js index 0ca39bcee..120805fe4 100644 --- a/packages/rito-core-wasm/src/reader-session-worker-query-runtime.js +++ b/packages/rito-core-wasm/src/reader-session-worker-query-runtime.js @@ -33,7 +33,10 @@ export function handleRitoReaderSessionQuery(state, message, guards) { const { session } = state; switch (message.kind) { case 'peek-adjacent': - guards.requireArtifactCapacity(state); + guards.requireArtifactCapacity( + state, + guards.artifactSpreadMode(state, message.request.fromArtifactId), + ); guards.requireOwnedArtifact(state, message.request.fromArtifactId); return peekedArtifactResponse( state, @@ -42,7 +45,10 @@ export function handleRitoReaderSessionQuery(state, message, guards) { session.peekAdjacent(encodeRitoReaderAdjacentRequest(message.request)), ); case 'peek-spread': - guards.requireArtifactCapacity(state); + guards.requireArtifactCapacity( + state, + guards.artifactSpreadMode(state, message.request.fromArtifactId), + ); guards.requireOwnedArtifact(state, message.request.fromArtifactId); return peekedArtifactResponse( state, @@ -119,7 +125,10 @@ function peekedArtifactResponse(state, guards, request, raw) { } throw guards.workerError('invalid-wire', 'Peeked artifact identity does not match its request'); } - state.liveArtifacts.add(identity.artifactId); + state.liveArtifacts.set( + identity.artifactId, + guards.artifactSpreadMode(state, request.fromArtifactId), + ); state.peekedArtifacts.set(identity.artifactId, identity.requestId); const wire = wireBytes.buffer; return { payload: { kind: 'artifact', identity, wire }, transfer: [wire] }; diff --git a/packages/rito-core-wasm/src/reader-session-worker-runtime.d.ts b/packages/rito-core-wasm/src/reader-session-worker-runtime.d.ts index a821d4154..7257ae3f5 100644 --- a/packages/rito-core-wasm/src/reader-session-worker-runtime.d.ts +++ b/packages/rito-core-wasm/src/reader-session-worker-runtime.d.ts @@ -4,8 +4,10 @@ import type { RitoReaderSessionWorkerScope, } from './types'; -/** How many artifacts a session keeps live at once: the engine's cap. */ +/** How many artifacts a session of pages keeps live at once: the engine's cap. */ export declare const READER_LIVE_ARTIFACT_CAP: number; +/** How many artifacts a scrolled session keeps live at once: the engine's cap. */ +export declare const READER_SCROLLED_LIVE_ARTIFACT_CAP: number; export declare function createRitoCoreWasmReaderSessionWorkerHandler( scope: RitoReaderSessionWorkerScope, diff --git a/packages/rito-core-wasm/src/reader-session-worker-runtime.js b/packages/rito-core-wasm/src/reader-session-worker-runtime.js index af6b06709..a6233bb1a 100644 --- a/packages/rito-core-wasm/src/reader-session-worker-runtime.js +++ b/packages/rito-core-wasm/src/reader-session-worker-runtime.js @@ -23,13 +23,17 @@ import { prepareHostFontFaces } from './pinned-font-policy-runtime.js'; const PROTOCOL = 'rito-reader-session'; const COLD_OPEN_DIAGNOSTIC = 'cold-open-attribution-v1'; const MAX_QUEUED_MESSAGES = 8; -// The engine's live-artifact cap (READER_LIVE_ARTIFACT_CAP), which is -// budgeted for a reader that loads its neighbours ahead: the visible spread, -// the one a turn is leaving, a neighbour on each side and a candidate under -// way. The worker refuses at the same number so that a host is told +// The engine's live-artifact caps (READER_LIVE_ARTIFACT_CAP and +// READER_SCROLLED_LIVE_ARTIFACT_CAP). A reader of pages loads its neighbours +// ahead: the visible spread, the one a turn is leaving, a neighbour on each +// side and a candidate under way. A scrolled reader holds the windows in +// view (a short chapter's can be several), the ones scrolling in on either +// side and a candidate. The worker refuses at the same numbers, by the +// spread mode of the revision an artifact comes from, so that a host is told // `artifact-capacity` instead of the engine's generic refusal; a test holds -// the two equal. +// them equal. export const READER_LIVE_ARTIFACT_CAP = 6; +export const READER_SCROLLED_LIVE_ARTIFACT_CAP = 10; const NON_FATAL_EXACT_TERMINAL_CODES = new Set([ 'invalid-request', 'invalid-layout', @@ -47,7 +51,9 @@ export function createRitoCoreWasmReaderSessionWorkerHandler(scope, deps) { session: undefined, sessionId: undefined, visibleArtifactId: undefined, - liveArtifacts: new Set(), + // Each live artifact's spread mode, which sets the cap its revision's + // requests answer to. + liveArtifacts: new Map(), foregroundArtifactRequests: new Map(), backgroundCandidates: new Map(), peekedArtifacts: new Map(), @@ -111,13 +117,13 @@ async function handleMessage(state, deps, message) { } case 'request-artifact': state.foregroundArtifactRequests.clear(); - requireArtifactCapacity(state); + requireArtifactCapacity(state, message.request.layout.spreadMode); return exactArtifactResponse(state, message.request, () => state.session.requestArtifact(encodeRitoReaderArtifactRequest(message.request)), ); case 'request-adjacent': state.foregroundArtifactRequests.clear(); - requireArtifactCapacity(state); + requireArtifactCapacity(state, artifactSpreadMode(state, message.request.fromArtifactId)); requireOwnedArtifact(state, message.request.fromArtifactId); return adjacentArtifactAttemptResponse(state, message.request, (request) => state.session.requestAdjacent(encodeRitoReaderAdjacentRequest(request)), @@ -132,7 +138,10 @@ async function handleMessage(state, deps, message) { case 'advance-background-once': requireVisibleArtifact(state, message.request.expectedVisibleArtifactId); if (!hasPendingCandidate(state, message.request.expectedVisibleArtifactId)) { - requireArtifactCapacity(state); + requireArtifactCapacity( + state, + artifactSpreadMode(state, message.request.expectedVisibleArtifactId), + ); } return backgroundAdvanceResponse( state, @@ -230,6 +239,7 @@ async function handleMessage(state, deps, message) { default: { const answered = handleRitoReaderSessionQuery(state, message, { requireArtifactCapacity, + artifactSpreadMode, requireOwnedArtifact, disposeTerminalSession, workerError: readerWorkerError, @@ -486,7 +496,10 @@ function artifactResponse(state, request, raw, kind = 'artifact') { } throw readerWorkerError('invalid-wire', 'Reader artifact identity does not match its request'); } - state.liveArtifacts.add(identity.artifactId); + state.liveArtifacts.set( + identity.artifactId, + request.layout?.spreadMode ?? artifactSpreadMode(state, request.fromArtifactId), + ); state.foregroundArtifactRequests.set(identity.artifactId, identity.requestId); const wire = wireBytes.buffer; return { payload: { kind, identity, wire }, transfer: [wire] }; @@ -532,17 +545,18 @@ function backgroundAdvanceResponse(state, request, raw) { throw readerWorkerError('invalid-wire', 'Background replacement identity is invalid'); } if (candidate) { + const spreadMode = artifactSpreadMode(state, request.expectedVisibleArtifactId); const candidateIsValid = candidate.sessionId === state.sessionId && candidate.requestId === advance.intentRequestId && !state.liveArtifacts.has(candidate.artifactId) && - state.liveArtifacts.size < READER_LIVE_ARTIFACT_CAP && + state.liveArtifacts.size < liveArtifactCap(spreadMode) && !hasPendingCandidate(state, request.expectedVisibleArtifactId); if (!candidateIsValid) { releaseInvalidCandidate(state, candidate); throw readerWorkerError('invalid-wire', 'Background candidate identity is invalid'); } - state.liveArtifacts.add(candidate.artifactId); + state.liveArtifacts.set(candidate.artifactId, spreadMode); state.backgroundCandidates.set(candidate.artifactId, { expectedVisibleArtifactId: request.expectedVisibleArtifactId, intentRequestId: advance.intentRequestId, @@ -623,15 +637,24 @@ function requireOpen(state) { } } -function requireArtifactCapacity(state) { - if (state.liveArtifacts.size >= READER_LIVE_ARTIFACT_CAP) { - throw readerWorkerError( - 'artifact-capacity', - `Reader retains at most ${String(READER_LIVE_ARTIFACT_CAP)} artifacts`, - ); +function requireArtifactCapacity(state, spreadMode) { + const cap = liveArtifactCap(spreadMode); + if (state.liveArtifacts.size >= cap) { + throw readerWorkerError('artifact-capacity', `Reader retains at most ${String(cap)} artifacts`); } } +function liveArtifactCap(spreadMode) { + return spreadMode === 'scrolled' ? READER_SCROLLED_LIVE_ARTIFACT_CAP : READER_LIVE_ARTIFACT_CAP; +} + +// The spread mode of a live artifact's revision; a single-page spread for one +// this worker does not hold, whose request fails on it after the capacity +// check, as the engine's does. +function artifactSpreadMode(state, artifactId) { + return state.liveArtifacts.get(artifactId) ?? 'single'; +} + function requireOwnedArtifact(state, artifactId) { if (!state.liveArtifacts.has(artifactId)) { throw readerWorkerError('unknown-artifact', 'Reader artifact is not live in this worker'); diff --git a/packages/rito-core-wasm/src/types/reader-session-query.ts b/packages/rito-core-wasm/src/types/reader-session-query.ts index 97f715737..5f6531842 100644 --- a/packages/rito-core-wasm/src/types/reader-session-query.ts +++ b/packages/rito-core-wasm/src/types/reader-session-query.ts @@ -182,11 +182,25 @@ export interface RitoReaderTextInteractionRequest extends RitoReaderGeometryOpti readonly query: RitoReaderTextInteractionQuery; } +/** + * A caret in display-list space: the segment across its line, as a rect with + * no extent along the line — `width` 0 on a horizontal line, `height` 0 down a + * vertical one. + */ +export interface RitoReaderCaretGeometry { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** The writing mode of the line a caret is on: which way its arrow keys move it. */ +export type RitoReaderWritingMode = 'horizontal-tb' | 'vertical-rl'; + export interface RitoReaderCaret { readonly address: RitoReaderCaretAddress; - readonly geometry?: - | { readonly x: number; readonly y: number; readonly height: number } - | undefined; + readonly geometry?: RitoReaderCaretGeometry | undefined; + readonly writingMode: RitoReaderWritingMode; readonly href: string; readonly sourcePoint: RitoReaderSourcePoint; } @@ -324,6 +338,11 @@ export interface RitoReaderRevisionLayout { readonly spineIndex: number; readonly startPage: number; readonly endPage: number; + /** + * A scrolled chapter's extent along the scroll axis, in CSS pixels: its + * margins and its strip. Absent in a revision of pages. + */ + readonly extent?: number | undefined; }[]; /** The TOC entries the revision places on a page, in TOC order. */ readonly tocTargets: readonly { readonly tocId: number; readonly pageIndex: number }[]; @@ -335,8 +354,16 @@ export interface RitoReaderRevisionLayout { * entry whose target lies past them is not placed yet. */ readonly complete: boolean; + /** + * The direction a scrolled revision's chapters run, each following the one + * before it: `down` as horizontal text's blocks stack, `left` as vertical + * right-to-left text's columns follow. Absent for a revision of pages. + */ + readonly scrollAxis?: RitoReaderScrollAxis | undefined; } +export type RitoReaderScrollAxis = 'down' | 'left'; + export type RitoReaderLocation = | { readonly kind: 'page'; @@ -344,6 +371,12 @@ export type RitoReaderLocation = /** Whether the asking artifact draws that page. */ readonly drawn: boolean; readonly matchedBy: 'source-range' | 'source-point' | 'anchor' | 'progression' | 'href'; + /** + * In a scrolled revision, where the line the locator lands on starts + * in that page's window, as an artifact's `targetBlockOffset`; absent + * for a page, and for a locator that names no line. + */ + readonly blockOffset?: number | undefined; } | { readonly kind: 'not-laid-out' } | { readonly kind: 'unavailable' }; diff --git a/packages/rito-core-wasm/src/types/reader-session.ts b/packages/rito-core-wasm/src/types/reader-session.ts index 220511ca0..77d6f2a0c 100644 --- a/packages/rito-core-wasm/src/types/reader-session.ts +++ b/packages/rito-core-wasm/src/types/reader-session.ts @@ -1,7 +1,20 @@ import type { RitoReaderRect } from './reader-session-display'; import type { RitoReaderPrimitiveList } from './reader-session-primitive'; -export type RitoReaderSpreadMode = 'single' | 'double'; +/** + * `scrolled` lays each chapter out as one continuous strip read through + * windows the size of the viewport, every window its own spread. A book + * whose pages progress right to left scrolls leftwards, others down (the + * revision layout's `scrollAxis`); a chapter whose blocks run across the + * book's axis is paginated instead, each page one window. Window `k` of a + * strip covers the chapter's CSS block range `[k·L, (k+1)·L)` from its block + * start (`L` the viewport's height, or its width for a leftward strip; the + * last window ends at the chapter's extent) and the device pixels + * `[D(k), D(k+1))` along the axis, `D(k) = floor(k·L·ratio + 0.5)`, the last + * ending at `floor(extent·ratio + 0.5)`: the windows tile the chapter. A + * leftward strip starts at the right edge of its first window. + */ +export type RitoReaderSpreadMode = 'single' | 'double' | 'scrolled'; export type RitoReaderTextProfile = 'platform-string-runs' | 'positioned-glyph-runs'; export type RitoReaderResourceKind = 'image' | 'font' | 'stylesheet'; export type RitoReaderAdjacentDirection = 'previous' | 'next'; @@ -182,6 +195,18 @@ export interface RitoReaderFontRef { readonly weight: number; readonly shapeFingerprint: string; readonly byteLength: bigint; + /** + * OpenType features the face turns on wherever its family paints: the + * same bytes registered again under a family of their own (the vertical + * forms a vertical line's upright glyphs take). + */ + readonly features: readonly RitoReaderFontFeature[]; +} + +/** One OpenType feature setting: its four-letter tag and value. */ +export interface RitoReaderFontFeature { + readonly tag: string; + readonly value: number; } /** @@ -286,6 +311,15 @@ export interface RitoReaderArtifact { readonly bookPageIndex?: number | undefined; /** The whole book's page count, present together with `bookPageIndex`. */ readonly bookPageCount?: number | undefined; + /** + * Where the line the artifact's locator lands on starts in its scrolled + * window's display list, in CSS pixels from the window's block start (its + * top, or its right edge for a leftward strip): the window is shown from + * here to put that line at the block start of the viewport's content box. + * Absent for a spread of pages, for a page shown as a window, and for a + * window whose locator names no line. + */ + readonly targetBlockOffset?: number | undefined; readonly navigation: { readonly previous: RitoReaderAdjacentAvailability; readonly next: RitoReaderAdjacentAvailability; diff --git a/packages/rito-core-wasm/tests/fixtures/reader-session-artifact.hex b/packages/rito-core-wasm/tests/fixtures/reader-session-artifact.hex index 1ba946ec0..1a87e4010 100644 --- a/packages/rito-core-wasm/tests/fixtures/reader-session-artifact.hex +++ b/packages/rito-core-wasm/tests/fixtures/reader-session-artifact.hex @@ -1 +1 @@ -5249544f4152543101000000860f0000000000000700000001000000010000000000400002000000000080000300000000000001feffffff0400000000000002980000000000000015000000546578742f636861707465722d30312e7868746d6c0107000000e7af802de4b880011800000000000000030000000000000003000000ffffffff0b000000000001000140000000000000001800000000000000030000000000000003000000ffffffff0b000000000001001800000000000000030000000000000004000000020000000d0000000000020001000000000000e43f00000000090000000500000002000000090000000a0000000000000000148a400000000000a99240018c0000000138010000000000000100000000000000830d000000000000040000000e000000730d0000000000005249544f444c310400000000000000000000400e000000010002000300000000000000f03f00000000000000400400000000000000e03f0500000000000000f03f00000000000000400300000001000000000000e03f0200000000000000400000000000000840030000000000001040000000000000144006000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e400607000000000000000000000000000000000000000000000044400000000000004e40010000803e0000003f0000403f0000803f000208000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e400602010000803e0000003f0000403f0000803f0003000000000000e03f000000000000e03f00000000008043400000000000804d4009000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e4006000000000000f83f010000803e0000003f0000403f0000803f000201000000000000084000000000000000400a000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e4006000000000000f83f000000000000f03f0000000000000040010000803e0000003f0000403f0000803f00010600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e40060b0010000000696d616765732f636f7665722e6a70670000000000000000000000000000000000000000000044400000000000004e400001000000000000000000000000000000000000000000003040000000000000304002000000030000000c00000000000400000000000000040000000000000000000000000000000000000000000000000034400000000000003e40000000000100000000008032400105000000236e6f74650106000000736f7572636501090000000000000000000000040000000d00040000000400000004000000040000000000000000000000000000000000000000000000000034400000000000003e40000000000100000000008032400105000000236e6f74650106000000736f7572636501090000000000000004000000040000000e0010000000696d616765732f636f7665722e6a7067020000000000000000000000000000000000000000000044400000000000004e40b81e85eb51b8ae3f9a9999999999d93f02000000565555555555edbf565555555555e5bf010b000000015555555555552240000000000000344002abaaaaaaaaaa3440000000000000344002abaaaaaaaaea3340abaaaaaaaa2a3640025655555555553240abaaaaaaaaaa3a400200000000008034400000000000403d4002abaaaaaaaa6a3240555555555515404002abaaaaaaaa2a3440abaaaaaaaa2a4140025655555555d53240000000000000434002abaaaaaaaa2a324000000000000044400255555555555522400000000000004440061b000000015655555555552c40abaaaaaaaa9a3640025755555555d53940abaaaaaaaa9a3640035755555555d539400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f9bf182d4454fb21f93f02acaaaaaaaa3a3c400000000000804140035755555555d539400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a03400000000000000000182d4454fb21f93f025655555555552c40abaaaaaaaab24240035655555555552c400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f93f182d4454fb21f93f02abaaaaaaaa8a27400000000000003940035655555555552c400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb210940182d4454fb21f93f06015655555555552c400000000000103840025655555555d539400000000000103840035655555555d539400000000000003940000000000000ee3f000000000000ee3f182d4454fb21f9bf182d4454fb21f93f025655555555c53a400000000000804140035655555555d539400000000000804140000000000000ee3f000000000000ee3f0000000000000000182d4454fb21f93f025655555555552c400000000000f84140035655555555552c400000000000804140000000000000ee3f000000000000ee3f182d4454fb21f93f182d4454fb21f93f025655555555752a400000000000003940035655555555552c400000000000003940000000000000ee3f000000000000ee3f182d4454fb210940182d4454fb21f93f060456555555551531400000000000803b40565555555555f73f565555555555f73f01abaaaaaaaaaa2a40555555555595414002aaaaaaaaaaaa31400000000000803e40020000000000803440abaaaaaaaaaa404002abaaaaaaaaea3640aaaaaaaaaaea3e4002abaaaaaaaaaa3a40555555555595414006565555555555ed3f565555555555e53f010b000000015655555555d53e400000000000003440025655555555d535400000000000003440025555555555153540abaaaaaaaa2a3640020000000000803340abaaaaaaaaaa3a4002abaaaaaaaaaa35400000000000403d400256555555559533405555555555154040025655555555553540abaaaaaaaa2a414002000000000000344000000000000043400256555555555533400000000000004440025655555555d53e400000000000004440061b000000015655555555552c40abaaaaaaaa9a3640025755555555d53940abaaaaaaaa9a3640035755555555d539400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f9bf182d4454fb21f93f02acaaaaaaaa3a3c400000000000804140035755555555d539400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a03400000000000000000182d4454fb21f93f025655555555552c40abaaaaaaaab24240035655555555552c400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f93f182d4454fb21f93f02abaaaaaaaa8a27400000000000003940035655555555552c400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb210940182d4454fb21f93f06015655555555552c400000000000103840025655555555d539400000000000103840035655555555d539400000000000003940000000000000ee3f000000000000ee3f182d4454fb21f9bf182d4454fb21f93f025655555555c53a400000000000804140035655555555d539400000000000804140000000000000ee3f000000000000ee3f0000000000000000182d4454fb21f93f025655555555552c400000000000f84140035655555555552c400000000000804140000000000000ee3f000000000000ee3f182d4454fb21f93f182d4454fb21f93f025655555555752a400000000000003940035655555555552c400000000000003940000000000000ee3f000000000000ee3f182d4454fb210940182d4454fb21f93f060456555555551531400000000000803b40565555555555f73f565555555555f73f01abaaaaaaaaaa2a40555555555595414002aaaaaaaaaaaa31400000000000803e40020000000000803440abaaaaaaaaaa404002abaaaaaaaaea3640aaaaaaaaaaea3e4002abaaaaaaaaaa3a40555555555595414006080000000000000000000001000000020000000300000000000000010000000200000003000000000000000000484100000841000048410000444100004841000080410000484100000000000048410000084100004841000044410000484100008041000048410001000000010000080000007465787474657874010000000a0000005269746f20536572696600000000000030400000000000e0854002010000000000000000000000000000803f0001000000000000000000f03f00000000000000400000000000000840018988883eabaaaa3ecdcccc3e0000803f000300000018000000000000000000000010000000696d616765732f636f7665722e6a706719000000000000000100000011000000666f6e74732f73657269662e776f6666321700000000000000020000000f0000007374796c65732f626f6f6b2e637373010000004d00000000000000110000004e6f746f20536572696620434a4b204a5011000000666f6e74732f73657269662e776f666632060000006e6f726d616c8a020b0000007368613235363a6162636407000000000100000100000014000000000000000900000000000000000479400000000000a99240 +5249544f41525431010000009f0f0000000000000900000001000000010000000000400002000000000080000300000000000001feffffff0400000000000002980000000000000015000000546578742f636861707465722d30312e7868746d6c0107000000e7af802de4b880011800000000000000030000000000000003000000ffffffff0b000000000001000140000000000000001800000000000000030000000000000003000000ffffffff0b000000000001001800000000000000030000000000000004000000020000000d0000000000020001000000000000e43f00000000090000000500000002000000090000000a0000000000000000148a400000000000a99240018c00000001380100000100000000409a7740000000000100000000000000830d000000000000040000000e000000730d0000000000005249544f444c310400000000000000000000400e000000010002000300000000000000f03f00000000000000400400000000000000e03f0500000000000000f03f00000000000000400300000001000000000000e03f0200000000000000400000000000000840030000000000001040000000000000144006000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e400607000000000000000000000000000000000000000000000044400000000000004e40010000803e0000003f0000403f0000803f000208000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e400602010000803e0000003f0000403f0000803f0003000000000000e03f000000000000e03f00000000008043400000000000804d4009000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e4006000000000000f83f010000803e0000003f0000403f0000803f000201000000000000084000000000000000400a000600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e4006000000000000f83f000000000000f03f0000000000000040010000803e0000003f0000403f0000803f00010600000001000000000000f03f0000000000000040020000000000000840000000000000104003000000000000144000000000000018400000000000001c4000000000000020400000000000000000000000000000f83f040000000000002240000000000000244000000000000000400000000000000840050000000000000000000000000000000000000000000034400000000000003e40060b0010000000696d616765732f636f7665722e6a70670000000000000000000000000000000000000000000044400000000000004e400001000000000000000000000000000000000000000000003040000000000000304002000000030000000c00000000000400000000000000040000000000000000000000000000000000000000000000000034400000000000003e40000000000100000000008032400105000000236e6f74650106000000736f7572636501090000000000000000000000040000000d00040000000400000004000000040000000000000000000000000000000000000000000000000034400000000000003e40000000000100000000008032400105000000236e6f74650106000000736f7572636501090000000000000004000000040000000e0010000000696d616765732f636f7665722e6a7067020000000000000000000000000000000000000000000044400000000000004e40b81e85eb51b8ae3f9a9999999999d93f02000000565555555555edbf565555555555e5bf010b000000015555555555552240000000000000344002abaaaaaaaaaa3440000000000000344002abaaaaaaaaea3340abaaaaaaaa2a3640025655555555553240abaaaaaaaaaa3a400200000000008034400000000000403d4002abaaaaaaaa6a3240555555555515404002abaaaaaaaa2a3440abaaaaaaaa2a4140025655555555d53240000000000000434002abaaaaaaaa2a324000000000000044400255555555555522400000000000004440061b000000015655555555552c40abaaaaaaaa9a3640025755555555d53940abaaaaaaaa9a3640035755555555d539400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f9bf182d4454fb21f93f02acaaaaaaaa3a3c400000000000804140035755555555d539400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a03400000000000000000182d4454fb21f93f025655555555552c40abaaaaaaaab24240035655555555552c400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f93f182d4454fb21f93f02abaaaaaaaa8a27400000000000003940035655555555552c400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb210940182d4454fb21f93f06015655555555552c400000000000103840025655555555d539400000000000103840035655555555d539400000000000003940000000000000ee3f000000000000ee3f182d4454fb21f9bf182d4454fb21f93f025655555555c53a400000000000804140035655555555d539400000000000804140000000000000ee3f000000000000ee3f0000000000000000182d4454fb21f93f025655555555552c400000000000f84140035655555555552c400000000000804140000000000000ee3f000000000000ee3f182d4454fb21f93f182d4454fb21f93f025655555555752a400000000000003940035655555555552c400000000000003940000000000000ee3f000000000000ee3f182d4454fb210940182d4454fb21f93f060456555555551531400000000000803b40565555555555f73f565555555555f73f01abaaaaaaaaaa2a40555555555595414002aaaaaaaaaaaa31400000000000803e40020000000000803440abaaaaaaaaaa404002abaaaaaaaaea3640aaaaaaaaaaea3e4002abaaaaaaaaaa3a40555555555595414006565555555555ed3f565555555555e53f010b000000015655555555d53e400000000000003440025655555555d535400000000000003440025555555555153540abaaaaaaaa2a3640020000000000803340abaaaaaaaaaa3a4002abaaaaaaaaaa35400000000000403d400256555555559533405555555555154040025655555555553540abaaaaaaaa2a414002000000000000344000000000000043400256555555555533400000000000004440025655555555d53e400000000000004440061b000000015655555555552c40abaaaaaaaa9a3640025755555555d53940abaaaaaaaa9a3640035755555555d539400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f9bf182d4454fb21f93f02acaaaaaaaa3a3c400000000000804140035755555555d539400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a03400000000000000000182d4454fb21f93f025655555555552c40abaaaaaaaab24240035655555555552c400000000000804140abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb21f93f182d4454fb21f93f02abaaaaaaaa8a27400000000000003940035655555555552c400000000000003940abaaaaaaaa2a0340abaaaaaaaa2a0340182d4454fb210940182d4454fb21f93f06015655555555552c400000000000103840025655555555d539400000000000103840035655555555d539400000000000003940000000000000ee3f000000000000ee3f182d4454fb21f9bf182d4454fb21f93f025655555555c53a400000000000804140035655555555d539400000000000804140000000000000ee3f000000000000ee3f0000000000000000182d4454fb21f93f025655555555552c400000000000f84140035655555555552c400000000000804140000000000000ee3f000000000000ee3f182d4454fb21f93f182d4454fb21f93f025655555555752a400000000000003940035655555555552c400000000000003940000000000000ee3f000000000000ee3f182d4454fb210940182d4454fb21f93f060456555555551531400000000000803b40565555555555f73f565555555555f73f01abaaaaaaaaaa2a40555555555595414002aaaaaaaaaaaa31400000000000803e40020000000000803440abaaaaaaaaaa404002abaaaaaaaaea3640aaaaaaaaaaea3e4002abaaaaaaaaaa3a40555555555595414006080000000000000000000001000000020000000300000000000000010000000200000003000000000000000000484100000841000048410000444100004841000080410000484100000000000048410000084100004841000044410000484100008041000048410001000000010000080000007465787474657874010000000a0000005269746f20536572696600000000000030400000000000e0854002010000000000000000000000000000803f0001000000000000000000f03f00000000000000400000000000000840018988883eabaaaa3ecdcccc3e0000803f000300000018000000000000000000000010000000696d616765732f636f7665722e6a706719000000000000000100000011000000666f6e74732f73657269662e776f6666321700000000000000020000000f0000007374796c65732f626f6f6b2e637373010000005d00000000000000110000004e6f746f20536572696620434a4b204a5011000000666f6e74732f73657269662e776f666632060000006e6f726d616c8a020b0000007368613235363a616263640700000000010000010000000400000076657274010000000100000014000000000000000900000000000000000479400000000000a99240 diff --git a/packages/rito-core-wasm/tests/helpers/reader-session-fixtures.mjs b/packages/rito-core-wasm/tests/helpers/reader-session-fixtures.mjs index 7273e6661..634d1bb4e 100644 --- a/packages/rito-core-wasm/tests/helpers/reader-session-fixtures.mjs +++ b/packages/rito-core-wasm/tests/helpers/reader-session-fixtures.mjs @@ -42,6 +42,8 @@ export function artifactWire(sessionId, requestId, artifactId, href, options = { writer.u32(0, 'page index'); writer.f64(800, 'width'); writer.f64(600, 'height'); + // No book page index or count, no target block offset. + writer.option(undefined, () => undefined); writer.option(undefined, () => undefined); writer.option(undefined, () => undefined); writer.u32(2, 'previous'); diff --git a/packages/rito-core-wasm/tests/reader-session-artifact-wire.test.mjs b/packages/rito-core-wasm/tests/reader-session-artifact-wire.test.mjs index 1032a8468..160b684aa 100644 --- a/packages/rito-core-wasm/tests/reader-session-artifact-wire.test.mjs +++ b/packages/rito-core-wasm/tests/reader-session-artifact-wire.test.mjs @@ -22,13 +22,25 @@ const pageRecordsFixture = () => fixture('reader-session-page-records.hex'); test('decodes the artifact the Rust encoder writes, its pages by index and size', () => { const artifact = decodeRitoReaderArtifact(artifactFixture()); - assert.equal(artifact.protocolVersion, 7); + assert.equal(artifact.protocolVersion, 9); assert.equal(artifact.localSpreadIndex, 5); assert.deepEqual(artifact.localPageIndexes, [9, 10]); assert.equal(artifact.bookPageIndex, 140); assert.equal(artifact.bookPageCount, 312); + assert.equal(artifact.targetBlockOffset, 377.640625); assert.equal(artifact.displayList.commandCount, 14); assert.deepEqual(artifact.pages, [{ pageIndex: 9, width: 400.25, height: 1194.25 }]); + assert.deepEqual(artifact.fonts, [ + { + family: 'Noto Serif CJK JP', + href: 'fonts/serif.woff2', + style: 'normal', + weight: 650, + shapeFingerprint: 'sha256:abcd', + byteLength: (1n << 40n) + 7n, + features: [{ tag: 'vert', value: 1 }], + }, + ]); }); test('decodes the page records the Rust encoder writes, every hit field included', () => { diff --git a/packages/rito-core-wasm/tests/reader-session-layout.test.mjs b/packages/rito-core-wasm/tests/reader-session-layout.test.mjs index e8659aeaa..2614bde49 100644 --- a/packages/rito-core-wasm/tests/reader-session-layout.test.mjs +++ b/packages/rito-core-wasm/tests/reader-session-layout.test.mjs @@ -39,8 +39,8 @@ const LAYOUT = { { leftPageIndex: 3, rightPageIndex: 4 }, ], chapters: [ - { spineIndex: 1, startPage: 0, endPage: 2 }, - { spineIndex: 3, startPage: 3, endPage: 4 }, + { spineIndex: 1, startPage: 0, endPage: 2, extent: 4_200.5 }, + { spineIndex: 3, startPage: 3, endPage: 4, extent: 1_900.25 }, ], tocTargets: [ { tocId: 2, pageIndex: 0 }, @@ -48,6 +48,7 @@ const LAYOUT = { ], activeTocEntryByPage: [undefined, 2, 2, 6, 6], complete: false, + scrollAxis: 'left', }; test('the layout query and the spread request encode to the Rust bytes', () => { @@ -303,4 +304,23 @@ test('an artifact request encodes to the Rust bytes, force flags included', asyn const flagsOffset = 44 + Number(new DataView(expected.buffer).getBigUint64(36, true)) - 2; assert.deepEqual([...defaulted.subarray(flagsOffset, flagsOffset + 2)], [1, 1]); assert.deepEqual([...expected.subarray(flagsOffset, flagsOffset + 2)], [0, 1]); + // The spread mode follows the six margins and viewport sides; its tags are + // the engine's: single 0, double 1, scrolled 2. + const spreadModeAt = (bytes) => new DataView(bytes.buffer).getUint32(44 + 6 * 8, true); + assert.equal(spreadModeAt(expected), 1); + const scrolled = encodeRitoReaderArtifactRequest({ + ...request, + layout: { ...layout, spreadMode: 'scrolled' }, + }); + assert.equal(spreadModeAt(scrolled), 2); + assert.deepEqual( + [...scrolled.subarray(0, 44 + 6 * 8)], + [...expected.subarray(0, 44 + 6 * 8)], + 'only the spread mode differs before it', + ); + assert.throws( + () => + encodeRitoReaderArtifactRequest({ ...request, layout: { ...layout, spreadMode: 'free' } }), + /unknown spread mode/, + ); }); diff --git a/packages/rito-core-wasm/tests/reader-session-query-wire.test.mjs b/packages/rito-core-wasm/tests/reader-session-query-wire.test.mjs index b36a9bd23..d7485a584 100644 --- a/packages/rito-core-wasm/tests/reader-session-query-wire.test.mjs +++ b/packages/rito-core-wasm/tests/reader-session-query-wire.test.mjs @@ -185,7 +185,8 @@ test('text interaction messages match the Rust bytes', () => { position: { blockIndex: 2, lineIndex: 1, runIndex: 0, charIndex: 4 }, affinity: 'downstream', }, - geometry: { x: 12.5, y: 40, height: 18 }, + geometry: { x: 12.5, y: 40, width: 18, height: 0 }, + writingMode: 'vertical-rl', href: 'OEBPS/chapter-2.xhtml', sourcePoint: point(12), }); @@ -248,7 +249,7 @@ test('navigation messages match the Rust bytes', () => { ); assert.deepEqual(decodeRitoReaderNavigationResult(rustHex('LOCATION_HEX')), { kind: 'location', - location: { kind: 'page', pageIndex: 5, drawn: true, matchedBy: 'anchor' }, + location: { kind: 'page', pageIndex: 5, drawn: true, matchedBy: 'anchor', blockOffset: 212.25 }, }); assert.deepEqual(decodeRitoReaderNavigationResult(rustHex('TOC_HEX')), { kind: 'toc-entry', diff --git a/packages/rito-core-wasm/tests/reader-session-worker-facade.test.mjs b/packages/rito-core-wasm/tests/reader-session-worker-facade.test.mjs index e863b7625..e782873c9 100644 --- a/packages/rito-core-wasm/tests/reader-session-worker-facade.test.mjs +++ b/packages/rito-core-wasm/tests/reader-session-worker-facade.test.mjs @@ -15,6 +15,7 @@ import { decodeRitoReaderPublication } from '../src/reader-session-publication-r import { createRitoCoreWasmReaderSessionWorkerClient } from '../src/reader-session-worker-client-runtime.js'; import { READER_LIVE_ARTIFACT_CAP, + READER_SCROLLED_LIVE_ARTIFACT_CAP, createRitoCoreWasmReaderSessionWorkerHandler, } from '../src/reader-session-worker-runtime.js'; import { @@ -956,14 +957,99 @@ test('latest-wins seek keeps one active + one replaceable request and releases s assert.equal(initial.artifactId, 1n, 'visible source remains live until the host releases it'); }); -test('worker refuses at the live-artifact cap the engine declares', () => { +test('worker refuses at the live-artifact caps the engine declares', () => { const engine = readFileSync( new URL('../../../crates/rito-core/src/runtime/reader_session/session.rs', import.meta.url), 'utf8', ); - const declared = /pub const READER_LIVE_ARTIFACT_CAP: u32 = (\d+);/.exec(engine); - assert.ok(declared, 'the engine declares its live-artifact cap'); - assert.equal(READER_LIVE_ARTIFACT_CAP, Number(declared[1])); + const declared = (name) => { + const match = new RegExp(`pub const ${name}: u32 = (\\d+);`).exec(engine); + assert.ok(match, `the engine declares ${name}`); + return Number(match[1]); + }; + assert.equal(READER_LIVE_ARTIFACT_CAP, declared('READER_LIVE_ARTIFACT_CAP')); + assert.equal(READER_SCROLLED_LIVE_ARTIFACT_CAP, declared('READER_SCROLLED_LIVE_ARTIFACT_CAP')); +}); + +test('a scrolled session holds its own cap, and a window peeked from it counts against it', async () => { + const scope = workerScope(); + let artifactId = 0n; + const created = () => { + artifactId += 1n; + return artifactId; + }; + class RawSession { + requestArtifact(requestBytes) { + const identity = requestIdentity(requestBytes); + return artifactWire(identity.sessionId, identity.requestId, created(), 'Text/chapter.xhtml'); + } + + peekSpread(requestBytes) { + const reader = new ReaderWireReader(requestBytes); + reader.expectMagic('RITOSPQ1', 'magic'); + reader.u32('version'); + reader.u64('length'); + const sessionId = reader.externalId('session'); + const requestId = reader.externalId('request'); + return artifactWire(sessionId, requestId, created(), 'Text/chapter.xhtml'); + } + + releaseArtifact() { + return true; + } + + dispose() { + return true; + } + } + createRitoCoreWasmReaderSessionWorkerHandler(scope, { + initRitoCoreWasm: async () => undefined, + RitoReaderSession: RawSession, + }); + const scrolled = (requestId) => { + const base = request('Text/chapter.xhtml'); + return { + sessionId: 9n, + requestId, + ...base, + layout: { ...base.layout, spreadMode: 'scrolled' }, + }; + }; + const send = async (id, kind, body) => { + scope.dispatch({ protocol: 'rito-reader-session', id, kind, ...body }); + await settle(); + await settle(); + return scope.responses.at(-1).message; + }; + const peek = (id) => + send(id, 'peek-spread', { + request: { sessionId: 9n, requestId: BigInt(id), fromArtifactId: 1n, spreadIndex: 0 }, + }); + + assert.ok( + ( + await send(1, 'open', { + publication: new ArrayBuffer(1), + sessionId: 9n, + request: scrolled(1n), + }) + ).ok, + ); + for (let id = 2; id <= READER_LIVE_ARTIFACT_CAP; id += 1) { + assert.ok((await send(id, 'request-artifact', { request: scrolled(BigInt(id)) })).ok); + } + // Past the paged cap, a window peeked from a scrolled one still fits. + for (let id = READER_LIVE_ARTIFACT_CAP + 1; id <= READER_SCROLLED_LIVE_ARTIFACT_CAP; id += 1) { + assert.ok((await peek(id)).ok, `window ${String(id)} fits the scrolled cap`); + } + const refused = await peek(READER_SCROLLED_LIVE_ARTIFACT_CAP + 1); + assert.equal(refused.error.code, 'artifact-capacity'); + assert.equal(artifactId, BigInt(READER_SCROLLED_LIVE_ARTIFACT_CAP)); + // A request of pages answers to the paged cap whatever the session holds. + const paged = await send(READER_SCROLLED_LIVE_ARTIFACT_CAP + 2, 'request-artifact', { + request: { ...scrolled(BigInt(READER_SCROLLED_LIVE_ARTIFACT_CAP + 2)), layout }, + }); + assert.equal(paged.error.code, 'artifact-capacity'); }); test('worker bounds live artifact ownership and reopens capacity only after release', async () => { diff --git a/packages/rito/package.json b/packages/rito/package.json index 58a884e0f..4af6a5583 100644 --- a/packages/rito/package.json +++ b/packages/rito/package.json @@ -61,7 +61,7 @@ "@vitest/browser-playwright": "^4.1.12", "@vitest/coverage-v8": "^4.1.12", "fflate": "^0.8.2", - "playwright": "^1.59.1", + "playwright": "^1.64.1", "tsdown": "^0.21.7", "vitest": "^4.1.12" } diff --git a/packages/rito/playwright.config.ts b/packages/rito/playwright.config.ts index b1a0295cb..656b4963e 100644 --- a/packages/rito/playwright.config.ts +++ b/packages/rito/playwright.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from '@playwright/test'; -const browserChannel = process.env['PLAYWRIGHT_BROWSER_CHANNEL']; +// The full Chromium build, which rasterizes page and canvas text on the GPU +// as Chrome does; the headless shell rasterizes them on the CPU. +const browserChannel = process.env['PLAYWRIGHT_BROWSER_CHANNEL'] ?? 'chromium'; +// No smart minimum font size: Chromium lifts em- and %-sized text to a +// minimum its UI language and platform choose, which CSS does not define +// and the engine never applies. +const browserArgs = ['--blink-settings=minimumLogicalFontSize=0']; const DEFAULT_PIXEL_WORKERS = 2; export default defineConfig({ @@ -13,7 +19,8 @@ export default defineConfig({ reporter: [['list']], use: { browserName: 'chromium', - ...(browserChannel ? { channel: browserChannel } : {}), + channel: browserChannel, + launchOptions: { args: browserArgs }, headless: true, viewport: { width: 1400, height: 1800 }, deviceScaleFactor: 1, diff --git a/packages/rito/scripts/render-diagnostic-case.mjs b/packages/rito/scripts/render-diagnostic-case.mjs index 1a644a340..32dda6f16 100644 --- a/packages/rito/scripts/render-diagnostic-case.mjs +++ b/packages/rito/scripts/render-diagnostic-case.mjs @@ -77,9 +77,10 @@ async function run() { const server = await startDiagnosticServer(extractedDir); const browser = await chromium.launch({ - ...(process.env.PLAYWRIGHT_BROWSER_CHANNEL - ? { channel: process.env.PLAYWRIGHT_BROWSER_CHANNEL } - : {}), + channel: process.env.PLAYWRIGHT_BROWSER_CHANNEL ?? 'chromium', + // No smart minimum font size: Chromium lifts em- and %-sized text to a + // minimum its UI language and platform choose, which CSS does not define. + args: ['--blink-settings=minimumLogicalFontSize=0'], headless: true, }); diff --git a/packages/rito/src/bindings/browser/font-feature-settings.ts b/packages/rito/src/bindings/browser/font-feature-settings.ts new file mode 100644 index 000000000..614a23d75 --- /dev/null +++ b/packages/rito/src/bindings/browser/font-feature-settings.ts @@ -0,0 +1,14 @@ +/** One OpenType feature a font reference turns on. */ +export interface FontFeature { + readonly tag: string; + readonly value: number; +} + +/** + * The `font-feature-settings` descriptor a registered face takes for the + * features its font reference lists: each four-letter tag quoted with its + * value, as the descriptor's grammar writes them. + */ +export function fontFeatureSettings(features: readonly FontFeature[]): string { + return features.map((feature) => `"${feature.tag}" ${String(feature.value)}`).join(', '); +} diff --git a/packages/rito/src/bindings/browser/gl-pen/hardware.ts b/packages/rito/src/bindings/browser/gl-pen/hardware.ts index 699e9e347..f711f86dd 100644 --- a/packages/rito/src/bindings/browser/gl-pen/hardware.ts +++ b/packages/rito/src/bindings/browser/gl-pen/hardware.ts @@ -11,12 +11,28 @@ export function webglOnGpu(): boolean { const probe = new OffscreenCanvas(1, 1).getContext('webgl2', { failIfMajorPerformanceCaveat: true, }); - onGpu = probe !== null; + onGpu = probe !== null && !rendersInSoftware(probe); probe?.getExtension('WEBGL_lose_context')?.loseContext(); } return onGpu; } +/** + * Whether the renderer a context names is a software rasterizer. The + * caveat above does not refuse every one: Chromium on a machine with no + * GPU (a CI runner) creates the context on SwiftShader all the same. + */ +export function rendersInSoftware(gl: WebGL2RenderingContext): boolean { + const info = gl.getExtension('WEBGL_debug_renderer_info'); + const renderer = String( + info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), + ); + return SOFTWARE_RENDERERS.test(renderer); +} + +/** SwiftShader (Chromium), llvmpipe and softpipe (Mesa), WARP (Windows), Apple's software renderer. */ +const SOFTWARE_RENDERERS = /swiftshader|llvmpipe|softpipe|basic render|software/i; + let chromium: boolean | undefined; /** diff --git a/packages/rito/src/bindings/browser/reader-session-canvas-image-metadata.ts b/packages/rito/src/bindings/browser/reader-session-canvas-image-metadata.ts index 57632d598..24d6ae9ac 100644 --- a/packages/rito/src/bindings/browser/reader-session-canvas-image-metadata.ts +++ b/packages/rito/src/bindings/browser/reader-session-canvas-image-metadata.ts @@ -8,9 +8,11 @@ import { export interface BrowserReaderCanvasImageSource { readonly width: number; readonly height: number; - readonly mediaType: 'image/png' | 'image/jpeg'; + readonly mediaType: 'image/png' | 'image/gif' | 'image/webp' | 'image/bmp' | 'image/jpeg'; } +type Dimensions = { readonly width: number; readonly height: number }; + const JPEG_HEADER_SCAN_BYTES_MAX = 1024 * 1024; export function inspectBrowserReaderCanvasImage( @@ -45,17 +47,70 @@ function declaredDimensions( return { width: resource.width, height: resource.height }; } +/** + * The format and size of an image the browser can decode, by its bytes: + * the formats Core sizes (PNG, GIF, WebP, BMP, JPEG), read the way Core + * reads them. + */ function detectDimensions( bytes: Uint8Array, - declared: { readonly width: number; readonly height: number } | undefined, + declared: Dimensions | undefined, ): BrowserReaderCanvasImageSource | undefined { - if (isPng(bytes)) { - const dimensions = pngDimensions(bytes); - return dimensions ? { ...dimensions, mediaType: 'image/png' } : undefined; - } + const sized = ( + dimensions: Dimensions | undefined, + mediaType: BrowserReaderCanvasImageSource['mediaType'], + ): BrowserReaderCanvasImageSource | undefined => + dimensions ? { ...dimensions, mediaType } : undefined; + if (isPng(bytes)) return sized(pngDimensions(bytes), 'image/png'); + if (isGif(bytes)) return sized(gifDimensions(bytes), 'image/gif'); + if (isWebp(bytes)) return sized(webpDimensions(bytes), 'image/webp'); + if (isBmp(bytes)) return sized(bmpDimensions(bytes), 'image/bmp'); if (!isJpeg(bytes)) return undefined; - const dimensions = declared ?? jpegDimensions(bytes); - return dimensions ? { ...dimensions, mediaType: 'image/jpeg' } : undefined; + return sized(declared ?? jpegDimensions(bytes), 'image/jpeg'); +} + +function isGif(bytes: Uint8Array): boolean { + const signature = ascii(bytes, 0, 6); + return bytes.length >= 10 && (signature === 'GIF87a' || signature === 'GIF89a'); +} + +function gifDimensions(bytes: Uint8Array): Dimensions { + return { width: readU16Le(bytes, 6), height: readU16Le(bytes, 8) }; +} + +function isWebp(bytes: Uint8Array): boolean { + return bytes.length >= 30 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP'; +} + +function webpDimensions(bytes: Uint8Array): Dimensions | undefined { + switch (ascii(bytes, 12, 4)) { + // Extended: 24-bit canvas width and height, less one. + case 'VP8X': + return { width: readU24Le(bytes, 24) + 1, height: readU24Le(bytes, 27) + 1 }; + // Lossless: 14-bit fields packed after the one-byte signature. + case 'VP8L': { + if (bytes[20] !== 0x2f) return undefined; + const packed = readU16Le(bytes, 21) + readU16Le(bytes, 23) * 0x10000; + return { width: (packed & 0x3fff) + 1, height: (Math.floor(packed / 0x4000) & 0x3fff) + 1 }; + } + // Lossy: the frame header past its start code. + case 'VP8 ': + if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) return undefined; + return { width: readU16Le(bytes, 26) & 0x3fff, height: readU16Le(bytes, 28) & 0x3fff }; + default: + return undefined; + } +} + +function isBmp(bytes: Uint8Array): boolean { + return bytes.length >= 26 && ascii(bytes, 0, 2) === 'BM'; +} + +function bmpDimensions(bytes: Uint8Array): Dimensions | undefined { + const width = readI32Le(bytes, 18); + // A negative height encodes a top-down bitmap. + const height = Math.abs(readI32Le(bytes, 22)); + return width > 0 && height > 0 ? { width, height } : undefined; } function isPng(bytes: Uint8Array): boolean { @@ -108,6 +163,18 @@ function isStartOfFrame(marker: number): boolean { return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; } +function readU16Le(bytes: Uint8Array, offset: number): number { + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); +} + +function readU24Le(bytes: Uint8Array, offset: number): number { + return readU16Le(bytes, offset) + (bytes[offset + 2] ?? 0) * 0x10000; +} + +function readI32Le(bytes: Uint8Array, offset: number): number { + return readU16Le(bytes, offset) | (readU16Le(bytes, offset + 2) << 16); +} + function readU16Be(bytes: Uint8Array, offset: number): number { return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0); } diff --git a/packages/rito/src/bindings/browser/reader-session-canvas-resources.ts b/packages/rito/src/bindings/browser/reader-session-canvas-resources.ts index f6728e246..a5ce9f5a2 100644 --- a/packages/rito/src/bindings/browser/reader-session-canvas-resources.ts +++ b/packages/rito/src/bindings/browser/reader-session-canvas-resources.ts @@ -1,4 +1,5 @@ import type { CoreReaderPrimitiveList } from './core-contracts'; +import { fontFeatureSettings } from './font-feature-settings'; import type { BrowserReaderArtifact, BrowserReaderSession } from './reader-session'; import { BrowserReaderCanvasUnsupportedError } from './reader-session-canvas-error'; import type { BrowserReaderCanvasDecodedImage } from './reader-session-canvas-image-cache-support'; @@ -169,6 +170,9 @@ export class BrowserReaderCanvasResourceOwner { face = new FontFace(font.family, ownedArrayBuffer(resource.bytes), { style: font.style, weight: String(font.weight), + ...(font.features.length > 0 + ? { featureSettings: fontFeatureSettings(font.features) } + : {}), }); await face.load(); } catch (error: unknown) { diff --git a/packages/rito/src/bindings/browser/session-reader/commit.ts b/packages/rito/src/bindings/browser/session-reader/commit.ts index 4ef598054..a5fe67570 100644 --- a/packages/rito/src/bindings/browser/session-reader/commit.ts +++ b/packages/rito/src/bindings/browser/session-reader/commit.ts @@ -31,7 +31,7 @@ export async function commitSessionReaderLayout( state.layoutGeneration += 1; state.frames = new Map([[frame.spreadIndex, frame]]); state.pendingFrames.clear(); - state.view = createSessionReaderLayoutView(layout, scope, state.publication); + state.view = createSessionReaderLayoutView(layout, scope, state.publication, config); state.visible = frame; const last = state.view.spreads.length - 1; state.activeSpreadIndex = Math.min(Math.max(frame.spreadIndex + turned, 0), last); @@ -70,7 +70,12 @@ export async function completeSessionReaderLayout(state: SessionReaderState): Pr const generation = state.layoutGeneration; const layout = await readSessionReaderLayout(state, state.visible.artifact.artifactId); if (sessionReaderClosed(state) || generation !== state.layoutGeneration) return; - state.view = createSessionReaderLayoutView(layout, state.view.scope, state.publication); + state.view = createSessionReaderLayoutView( + layout, + state.view.scope, + state.publication, + state.config, + ); state.log.note('commit', { scope: state.view.scope, pages: state.view.pageCount, diff --git a/packages/rito/src/bindings/browser/session-reader/frames.ts b/packages/rito/src/bindings/browser/session-reader/frames.ts index e0b65dd70..8671487d3 100644 --- a/packages/rito/src/bindings/browser/session-reader/frames.ts +++ b/packages/rito/src/bindings/browser/session-reader/frames.ts @@ -52,17 +52,28 @@ export async function loadSessionReaderFrame( return frame; } +/** + * The spreads near the host's active one: its neighbours, and in a scrolled + * view the window after them too, which a viewport shows with the window + * read. + */ +function nearActive(state: SessionReaderState): readonly number[] { + const active = state.activeSpreadIndex; + const ahead = state.view.scroll === undefined ? [] : [active + 2]; + return [active + 1, active - 1, ...ahead]; +} + /** * Releases every held spread that is neither the session's visible one, the - * host's active one and its two neighbours, nor `keepIndex`. The session + * host's active one and the spreads near it, nor `keepIndex`. The session * holds a bounded number of artifacts, so what is not near is given back * before more is asked for. */ async function releaseFramesOutside(state: SessionReaderState, keepIndex: number): Promise { - const active = state.activeSpreadIndex; + const near = new Set([state.activeSpreadIndex, keepIndex, ...nearActive(state)]); const released: SessionReaderFrame[] = []; for (const [index, frame] of state.frames) { - if (frame === state.visible || index === keepIndex || Math.abs(index - active) <= 1) continue; + if (frame === state.visible || near.has(index)) continue; state.frames.delete(index); released.push(frame); } @@ -76,8 +87,7 @@ async function releaseFramesOutside(state: SessionReaderState, keepIndex: number */ export function warmSessionReaderNeighbours(state: SessionReaderState): void { if (state.bookHolds > 0) return; - const active = state.activeSpreadIndex; - for (const index of [active + 1, active - 1]) { + for (const index of nearActive(state)) { if (state.view.spreads[index] !== undefined) void ensureSessionReaderFrame(state, index); } } diff --git a/packages/rito/src/bindings/browser/session-reader/host-fonts.ts b/packages/rito/src/bindings/browser/session-reader/host-fonts.ts index 5dcea8973..3816c4097 100644 --- a/packages/rito/src/bindings/browser/session-reader/host-fonts.ts +++ b/packages/rito/src/bindings/browser/session-reader/host-fonts.ts @@ -1,4 +1,5 @@ import type { ReaderHostFontFace } from '../../../reader'; +import { fontFeatureSettings } from '../font-feature-settings'; import type { BrowserReaderArtifact } from '../reader-session'; type FontRef = BrowserReaderArtifact['fonts'][number]; @@ -12,8 +13,12 @@ interface HeldFace { readonly bytes: Blob; /** The face decoded from the bytes, not yet known to the page. */ readonly loaded: Promise; - /** The face given the descriptors the engine lists it with, and registered. */ - registered: Promise | undefined; + /** + * The faces registered from the bytes, by family: the face itself, given + * the descriptors the engine lists it with, and each variant the engine + * names with OpenType features of its own. + */ + readonly registered: Map>; } /** @@ -45,7 +50,7 @@ export class SessionReaderHostFonts { const bytes = new Blob([face.bytes]); const loaded = loadFace(HOST_FONT_FAMILY_PREFIX + sha256, bytes); loaded.catch(ignore); - this.held.set(sha256, { bytes, loaded, registered: undefined }); + this.held.set(sha256, { bytes, loaded, registered: new Map() }); } } @@ -54,24 +59,33 @@ export class SessionReaderHostFonts { const held = this.held.get(sha256.toLowerCase()); if (!held) continue; this.held.delete(sha256.toLowerCase()); - void held.registered?.then((face) => { - this.unregister(face); - }, ignore); + for (const registered of held.registered.values()) { + void registered.then((face) => { + this.unregister(face); + }, ignore); + } } } /** - * The registered face a font reference names. Undefined for a font that - * is not one of the reader's host faces. + * The registered face a font reference names: the host face itself, or + * a variant of it with the features the reference turns on. Undefined + * for a font that is not one of the reader's host faces. */ resolve(font: FontRef): Promise | undefined { if (this.disposed || !font.href.startsWith(HOST_FONT_HREF_PREFIX)) return undefined; const sha256 = font.href.slice(HOST_FONT_HREF_PREFIX.length); const held = this.held.get(sha256); - if (held === undefined || font.family !== HOST_FONT_FAMILY_PREFIX + sha256) return undefined; + if (held === undefined) return undefined; + const variant = font.features.length > 0; + if (!variant && font.family !== HOST_FONT_FAMILY_PREFIX + sha256) return undefined; if (BigInt(held.bytes.size) !== font.byteLength) return undefined; - held.registered ??= this.register(held, font); - return held.registered; + let registered = held.registered.get(font.family); + if (registered === undefined) { + registered = this.register(held, font); + held.registered.set(font.family, registered); + } + return registered; } dispose(): void { @@ -81,7 +95,12 @@ export class SessionReaderHostFonts { } private async register(held: HeldFace, font: FontRef): Promise { - const face = await held.loaded; + const face = + font.features.length > 0 + ? await loadFace(font.family, held.bytes, { + featureSettings: fontFeatureSettings(font.features), + }) + : await held.loaded; if (this.disposed) throw new Error('Reader closed while a host font face loaded'); face.style = font.style; face.weight = String(font.weight); @@ -95,15 +114,19 @@ export class SessionReaderHostFonts { } } -async function loadFace(family: string, bytes: Blob): Promise { +async function loadFace( + family: string, + bytes: Blob, + descriptors: FontFaceDescriptors = {}, +): Promise { const url = URL.createObjectURL(bytes); try { - const face = new FontFace(family, `url("${url}")`); + const face = new FontFace(family, `url("${url}")`, descriptors); await face.load(); return face; } catch { // A page whose policy refuses fonts from blob URLs still takes the bytes. - const face = new FontFace(family, await bytes.arrayBuffer()); + const face = new FontFace(family, await bytes.arrayBuffer(), descriptors); await face.load(); return face; } finally { diff --git a/packages/rito/src/bindings/browser/session-reader/layout-options.ts b/packages/rito/src/bindings/browser/session-reader/layout-options.ts index 5b4549dd1..27f54a03e 100644 --- a/packages/rito/src/bindings/browser/session-reader/layout-options.ts +++ b/packages/rito/src/bindings/browser/session-reader/layout-options.ts @@ -63,6 +63,11 @@ export function sessionReaderSessionLayout( }; } +/** A scrolled window's full length along `axis`: the viewport's height, or its width. */ +export function sessionReaderWindowLength(config: LayoutConfig, axis: 'down' | 'left'): number { + return axis === 'down' ? config.viewportHeight : config.viewportWidth; +} + export function sameSessionReaderLayoutConfig(first: LayoutConfig, second: LayoutConfig): boolean { const keys = new Set([...Object.keys(first), ...Object.keys(second)]) as Set; for (const key of keys) if (first[key] !== second[key]) return false; diff --git a/packages/rito/src/bindings/browser/session-reader/layout-view.ts b/packages/rito/src/bindings/browser/session-reader/layout-view.ts index ed9816840..bcc157884 100644 --- a/packages/rito/src/bindings/browser/session-reader/layout-view.ts +++ b/packages/rito/src/bindings/browser/session-reader/layout-view.ts @@ -1,5 +1,6 @@ -import type { ChapterRange, Spread } from '../../../reader'; +import type { ChapterRange, LayoutConfig, ReaderScrollLayout, Spread } from '../../../reader'; import type { BrowserReaderNavigationResult } from '../reader-session'; +import { sessionReaderWindowLength } from './layout-options'; import type { SessionReaderPublication } from './publication'; export type SessionReaderRevisionLayout = Extract< @@ -27,12 +28,15 @@ export interface SessionReaderLayoutView { readonly chapterHrefByPage: readonly (string | undefined)[]; /** Whether it holds every page: while a window is resized, only the pages laid out so far. */ readonly complete: boolean; + /** A scrolled revision's windows; absent for pages. */ + readonly scroll: ReaderScrollLayout | undefined; } export function createSessionReaderLayoutView( layout: SessionReaderRevisionLayout, scope: SessionReaderLayoutScope, publication: SessionReaderPublication, + config: LayoutConfig, ): SessionReaderLayoutView { const spreadIndexByPage = new Array(layout.pageCount).fill(undefined); const spreads = layout.spreads.map((pages, index): Spread => { @@ -70,6 +74,26 @@ export function createSessionReaderLayoutView( spreadIndexByPage, chapterHrefByPage, complete: layout.complete, + scroll: scrollLayout(layout, config, spreadIndexByPage), + }; +} + +/** A scrolled revision's chapters by the spreads of their windows; undefined for pages. */ +function scrollLayout( + layout: SessionReaderRevisionLayout, + config: LayoutConfig, + spreadIndexByPage: readonly (number | undefined)[], +): ReaderScrollLayout | undefined { + if (layout.scrollAxis === undefined) return undefined; + const chapters = layout.chapters.flatMap(({ startPage, endPage, extent }) => { + const firstSpread = spreadIndexByPage[startPage]; + if (firstSpread === undefined || extent === undefined) return []; + return [{ firstSpread, spreadCount: endPage - startPage + 1, extent }]; + }); + return { + axis: layout.scrollAxis, + windowLength: sessionReaderWindowLength(config, layout.scrollAxis), + chapters: chapters.sort((first, second) => first.firstSpread - second.firstSpread), }; } @@ -83,4 +107,5 @@ export const EMPTY_SESSION_READER_LAYOUT_VIEW: SessionReaderLayoutView = { spreadIndexByPage: [], chapterHrefByPage: [], complete: true, + scroll: undefined, }; diff --git a/packages/rito/src/bindings/browser/session-reader/navigation.ts b/packages/rito/src/bindings/browser/session-reader/navigation.ts index 13fdd32b3..04bc13c12 100644 --- a/packages/rito/src/bindings/browser/session-reader/navigation.ts +++ b/packages/rito/src/bindings/browser/session-reader/navigation.ts @@ -85,6 +85,7 @@ export async function locateSessionReaderLocator( pageIndex: location.pageIndex, spreadIndex, matchedBy: MATCHED_BY[location.matchedBy], + ...(location.blockOffset === undefined ? {} : { blockOffset: location.blockOffset }), }; } if (location.kind === 'unavailable' || state.view.scope === 'book') return undefined; diff --git a/packages/rito/src/bindings/browser/session-reader/reader.ts b/packages/rito/src/bindings/browser/session-reader/reader.ts index 8eb1ef560..53d851f46 100644 --- a/packages/rito/src/bindings/browser/session-reader/reader.ts +++ b/packages/rito/src/bindings/browser/session-reader/reader.ts @@ -210,6 +210,7 @@ function buildReader(state: SessionReaderState): Reader { ...sessionReaderListenerMethods(state), ...sessionReaderResourceMethods(state), getLayoutGeometry: () => state.geometry, + getScrollLayout: () => state.view.scroll, holdBookLayout: () => holdSessionReaderBook(state), dispose() { if (state.disposed) return Promise.resolve(); diff --git a/packages/rito/src/bindings/browser/session-reader/render.ts b/packages/rito/src/bindings/browser/session-reader/render.ts index e3ae40c57..61cac946a 100644 --- a/packages/rito/src/bindings/browser/session-reader/render.ts +++ b/packages/rito/src/bindings/browser/session-reader/render.ts @@ -63,10 +63,15 @@ function paintFrame( frame: SessionReaderFrame, target: CanvasRenderingTarget | ReaderGlTarget, ): boolean { - const { width, height } = frame.artifact; const size = 'gl' in target ? target : target.canvas; - const targetRatio = pixelRatioOf(size.width, size.height, width, height); - if (targetRatio === undefined) return false; + // A target takes the spread's canvas, or a scrolled window alone. + const window = state.view.scroll === undefined ? undefined : frame.artifact.pages[0]; + const fitted = [frame.artifact, ...(window === undefined ? [] : [window])].find( + (shape) => pixelRatioOf(size.width, size.height, shape.width, shape.height) !== undefined, + ); + if (fitted === undefined) return false; + const { width, height } = fitted; + const targetRatio = pixelRatioOf(size.width, size.height, width, height) ?? 1; // The artifact's paint is resolved on the device grid at its own // ratio; a target on that grid takes it as it is, and any other (a // host zoom) takes it uniformly scaled. diff --git a/packages/rito/src/bindings/browser/session-reader/text-selection.ts b/packages/rito/src/bindings/browser/session-reader/text-selection.ts index 6e0e1aec8..756370458 100644 --- a/packages/rito/src/bindings/browser/session-reader/text-selection.ts +++ b/packages/rito/src/bindings/browser/session-reader/text-selection.ts @@ -391,8 +391,10 @@ function bind( geometry: { x: caret.geometry.x - place.originX, y: caret.geometry.y - place.originY, + width: caret.geometry.width, height: caret.geometry.height, }, + writingMode: caret.writingMode, sourceLocator: { href: caret.href, sourcePoint: readerSourcePoint(caret.sourcePoint) }, } as ReaderTextCaret; bindings.set(bound, { generation: capture.generation, address: caret.address }); diff --git a/packages/rito/src/index.ts b/packages/rito/src/index.ts index a05d6dbd7..5158ca7b7 100644 --- a/packages/rito/src/index.ts +++ b/packages/rito/src/index.ts @@ -50,7 +50,9 @@ export type { ReaderPageReadingAnchor, ReaderPageSemantics, ReaderPageTargets, + ReaderScrollLayout, ReaderSemanticNode, + ReaderSpreadMode, ReaderTextRange, ReaderTextRangeFromPointsRequest, ReaderTextRangeFromPointsResolution, @@ -61,6 +63,7 @@ export type { ReaderSourceRange, ReaderTextCaret, ReaderTextCaretGeometry, + ReaderWritingMode, ReaderTextCaretResolution, ReaderTextInteractionUnavailableReason, ReaderTextPoint, diff --git a/packages/rito/src/reader/index.ts b/packages/rito/src/reader/index.ts index e1c18c03d..d9ad739b1 100644 --- a/packages/rito/src/reader/index.ts +++ b/packages/rito/src/reader/index.ts @@ -36,7 +36,9 @@ export type { ReaderPageReadingAnchor, ReaderPageSemantics, ReaderPageTargets, + ReaderScrollLayout, ReaderSemanticNode, + ReaderSpreadMode, ReaderAnnotationTarget, ReaderAnnotationTargetResolution, ReaderSourcePoint, @@ -71,6 +73,7 @@ export type { ReaderThemeOptions, ReaderTextCaret, ReaderTextCaretGeometry, + ReaderWritingMode, ReaderTextCaretResolution, ReaderTextInteractionUnavailableReason, ReaderTextPoint, diff --git a/packages/rito/src/reader/instance.ts b/packages/rito/src/reader/instance.ts index 9f46ff4be..a35eddf15 100644 --- a/packages/rito/src/reader/instance.ts +++ b/packages/rito/src/reader/instance.ts @@ -13,6 +13,8 @@ import type { ReaderMargin, ReaderPageReadingAnchor, ReaderPageSemantics, + ReaderScrollLayout, + ReaderSpreadMode, ReaderGlTarget, ReaderPageTargets, ReaderTextSelectionMovement, @@ -28,11 +30,23 @@ export interface ReaderTextPoint { readonly x: number; readonly y: number; } +/** + * A caret in page-content space: the segment across its line, as a rect with + * no extent along the line — `width` 0 on a horizontal line, `height` 0 down a + * vertical one. + */ export interface ReaderTextCaretGeometry { readonly x: number; readonly y: number; + readonly width: number; readonly height: number; } +/** + * The writing mode of the line a caret is on. An arrow key moves a caret on a + * `vertical-rl` line the way the key a quarter turn from it moves one on a + * horizontal line (down is forward along the line, left is the next line). + */ +export type ReaderWritingMode = 'horizontal-tb' | 'vertical-rl'; export type ReaderTextInteractionUnavailableReason = | 'shapeUnavailable' | 'sourceUnavailable' @@ -47,6 +61,7 @@ export interface ReaderTextCaret { /** Page owning this caret's page-local geometry. */ readonly pageIndex: number; readonly geometry: ReaderTextCaretGeometry; + readonly writingMode: ReaderWritingMode; readonly sourceLocator: ReaderLocator; } export type ReaderTextCaretResolution = @@ -281,7 +296,7 @@ export interface ReaderOptions { /** Durable source target for bindings that support locator-first initialization. */ readonly initialLocator?: ReaderLocator | undefined; readonly margin?: ReaderMargin; - readonly spread?: 'single' | 'double'; + readonly spread?: ReaderSpreadMode; readonly spreadGap?: number; readonly backgroundColor?: string | null; readonly foregroundColor?: string | null; @@ -341,11 +356,11 @@ export interface Reader { * `dpr` takes the new ratio when that layout commits. */ setDevicePixelRatio(ratio: number): void; - setSpreadMode(mode: 'single' | 'double'): boolean; + setSpreadMode(mode: ReaderSpreadMode): boolean; updateLayout( width: number, height: number, - spreadMode?: 'single' | 'double', + spreadMode?: ReaderSpreadMode, margin?: ReaderMargin, ): boolean; /** @@ -367,6 +382,8 @@ export interface Reader { findActiveTocEntry(pageIndex: number): TocEntry | undefined; getCanvasSize(scale?: number): { width: number; height: number }; getLayoutGeometry(): Readonly; + /** The committed layout's windows, when the reader is scrolled. */ + getScrollLayout(): ReaderScrollLayout | undefined; getImageBlobUrl(src: string): string | undefined | Promise; setTypography(opts: { fontSize?: number | null; diff --git a/packages/rito/src/reader/layout-config.ts b/packages/rito/src/reader/layout-config.ts index e9ea32976..e3d588146 100644 --- a/packages/rito/src/reader/layout-config.ts +++ b/packages/rito/src/reader/layout-config.ts @@ -3,7 +3,9 @@ export function createLayoutConfig(input: LayoutConfigInput): LayoutConfig { const { top, right, bottom, left } = resolveMargins(input.margin); const gap = input.spreadGap ?? 0; const requestedMode = input.spread ?? 'single'; - const effectiveMode = input.width < input.height ? 'single' : requestedMode; + // A portrait viewport holds one page a spread. + const effectiveMode = + requestedMode === 'double' && input.width < input.height ? 'single' : requestedMode; const pageWidth = effectiveMode === 'double' ? (input.width - gap) / 2 : input.width; return { viewportWidth: input.width, diff --git a/packages/rito/src/reader/model.ts b/packages/rito/src/reader/model.ts index bcceb4d59..635438529 100644 --- a/packages/rito/src/reader/model.ts +++ b/packages/rito/src/reader/model.ts @@ -28,7 +28,7 @@ export interface LayoutConfig { readonly marginRight: number; readonly marginBottom: number; readonly marginLeft: number; - readonly spreadMode: 'single' | 'double'; + readonly spreadMode: ReaderSpreadMode; readonly firstPageAlone: boolean; readonly spreadGap: number; readonly rootFontSize: number; @@ -38,6 +38,22 @@ export interface LayoutConfig { readonly fontFamilyForce?: boolean | undefined; } +/** A page or two a spread, or each chapter one strip read a window a spread. */ +export type ReaderSpreadMode = 'single' | 'double' | 'scrolled'; + +/** A scrolled reader's windows, end to end down the screen or leftwards (right to left books). */ +export interface ReaderScrollLayout { + readonly axis: 'down' | 'left'; + /** A full window's length along the axis in CSS pixels; a chapter's last ends with it. */ + readonly windowLength: number; + /** The chapters in reading order: their windows' spreads and their extent along the axis. */ + readonly chapters: readonly { + readonly firstSpread: number; + readonly spreadCount: number; + readonly extent: number; + }[]; +} + /** A page's margins in CSS pixels: one for all sides, `x` and `y`, or each side. */ export type ReaderMargin = | number @@ -53,7 +69,7 @@ export interface LayoutConfigInput { readonly width: number; readonly height: number; readonly margin?: ReaderMargin; - readonly spread?: 'single' | 'double'; + readonly spread?: ReaderSpreadMode; readonly firstPageAlone?: boolean; readonly spreadGap?: number; readonly rootFontSize?: number; @@ -171,6 +187,8 @@ export type ReaderLocatorResolution = readonly pageIndex: number; readonly spreadIndex: number; readonly matchedBy: ReaderLocatorMatchedBy; + /** Scrolled: where the locator's line starts in the spread's window, from its block start. */ + readonly blockOffset?: number | undefined; } | { readonly status: 'pending'; diff --git a/packages/rito/tests/unit/browser-reader-architecture-invariants.test.ts b/packages/rito/tests/unit/browser-reader-architecture-invariants.test.ts index a849328bd..b08105d95 100644 --- a/packages/rito/tests/unit/browser-reader-architecture-invariants.test.ts +++ b/packages/rito/tests/unit/browser-reader-architecture-invariants.test.ts @@ -51,8 +51,16 @@ const READER_ROOT_FILES = walkTs(READER_ROOT); // the session when the reader needs them, not read off every artifact. // +8 lines (2026-10-19): the whole book waits out the frame the first // page is drawn in. +// +42 lines (2026-10-27): a scrolled layout's windows by chapter, where a +// located line starts in its window, and a window painted alone. +// +10 lines (2026-10-27): a scrolled view also holds the window after the +// active one's neighbours, which a viewport shows with the window read. +// +2 lines (2026-10-27): a caret's width across its line and the writing +// mode of that line, which turns a vertical line's arrow keys. +// +23 lines (2026-10-29): a host face registers a variant the engine names +// with OpenType features (its vertical forms) as a face of its own. const SESSION_READER_FILE_BUDGET = 22; -const SESSION_READER_LINE_BUDGET = 2855; +const SESSION_READER_LINE_BUDGET = 2932; // Exact native interaction, point-granularity and keyboard-movement DTOs stay public // without exposing revision-local addresses. // +52 lines (2026-10-03): the font catalog types a font picker is built @@ -70,7 +78,13 @@ const SESSION_READER_LINE_BUDGET = 2855; // +5 lines (2026-10-19): `ReaderMargin`, a page's margins side by side. // +191 lines (2026-10-19): `loadPaperTurnModel`, the paper page turn's // motion and geometry, the engine's, which the kit's paper turn draws. -const READER_PUBLIC_CONTRACT_LINE_BUDGET = 1002; +// +7 lines (2026-10-27): `ReaderSpreadMode`, whose `scrolled` reads each +// chapter as one strip a window a spread. +// +19 lines (2026-10-27): `ReaderScrollLayout` and `getScrollLayout`, a +// scrolled reader's windows end to end, and where a located line starts. +// +14 lines (2026-10-27): `ReaderWritingMode` and a caret's width, the +// shape of a caret across a vertical line and which way its keys move it. +const READER_PUBLIC_CONTRACT_LINE_BUDGET = 1042; function walkTs(root: string): string[] { const out: string[] = []; diff --git a/packages/rito/tests/unit/browser-reader-canvas.test.ts b/packages/rito/tests/unit/browser-reader-canvas.test.ts index 7410e420c..84053bf77 100644 --- a/packages/rito/tests/unit/browser-reader-canvas.test.ts +++ b/packages/rito/tests/unit/browser-reader-canvas.test.ts @@ -260,6 +260,34 @@ describe('Browser reader session Canvas presenter', () => { presenter.dispose(); }); + it('decodes the GIF, WebP and BMP images Core sizes, by their own headers', async () => { + const headers: readonly (readonly [string, Uint8Array])[] = [ + ['image/gif', gifHeader(1000, 500)], + ['image/webp', webpHeader(1000, 500)], + ['image/bmp', bmpHeader(1000, 500)], + ]; + for (const [index, [mediaType, bytes]] of headers.entries()) { + const createBitmap = bitmapFactoryFromResizeOptions(1000, 500); + vi.stubGlobal('createImageBitmap', createBitmap); + const artifact = imageOnlyArtifact(readerArtifact(), 40n + BigInt(index), mediaType, 1); + const href = artifact.resources[0]?.href ?? ''; + readResource.mockResolvedValue({ + artifactId: artifact.artifactId, + kind: 'image', + href, + mediaType, + bytes, + }); + const presenter = createBrowserReaderSessionCanvasPresenter(readerSession(artifact)); + + const prepared = await presenter.prepare(artifact); + + expect(createBitmap).toHaveBeenCalledWith(expect.objectContaining({ type: mediaType })); + prepared.dispose(); + presenter.dispose(); + } + }); + it('prepares large resource sets in bounded batches below the worker queue limit', async () => { const hrefs = Array.from({ length: 12 }, (_, index) => `Images/page-${String(index)}.png`); const commands: Primitives = hrefs.map((src, index) => ({ @@ -564,6 +592,31 @@ function pngHeader(width: number, height: number): Uint8Array { return bytes; } +function gifHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(10); + bytes.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + bytes.set([width & 0xff, width >> 8, height & 0xff, height >> 8], 6); + return bytes; +} + +function webpHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(30); + const encoder = new TextEncoder(); + bytes.set(encoder.encode('RIFF'), 0); + bytes.set(encoder.encode('WEBPVP8X'), 8); + [width - 1, height - 1].forEach((value, index) => { + bytes.set([value & 0xff, (value >> 8) & 0xff, value >> 16], 24 + index * 3); + }); + return bytes; +} + +function bmpHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(26); + bytes.set([0x42, 0x4d]); + bytes.set([width & 0xff, width >> 8, 0, 0, height & 0xff, height >> 8, 0, 0], 18); + return bytes; +} + function jpegHeader(width: number, height: number): Uint8Array { const bytes = new Uint8Array(21); bytes.set([0xff, 0xd8, 0xff, 0xc0, 0, 17, 8]); @@ -638,6 +691,7 @@ function readerArtifact(): BrowserReaderArtifact { weight: 400, shapeFingerprint: 'font-shape-v1', byteLength: 2n, + features: [], }, ], pages: [], diff --git a/packages/rito/tests/unit/gl-pen-software-renderer.test.ts b/packages/rito/tests/unit/gl-pen-software-renderer.test.ts new file mode 100644 index 000000000..60ef75378 --- /dev/null +++ b/packages/rito/tests/unit/gl-pen-software-renderer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { rendersInSoftware } from '../../src/bindings/browser/gl-pen/hardware'; + +const UNMASKED_RENDERER_WEBGL = 0x9246; +const RENDERER = 0x1f01; + +/** A context naming `renderer`, behind the debug extension or, without it, as its own. */ +function contextNaming(renderer: string, debugInfo = true): WebGL2RenderingContext { + return { + RENDERER, + getExtension: (name: string) => + name === 'WEBGL_debug_renderer_info' && debugInfo ? { UNMASKED_RENDERER_WEBGL } : null, + getParameter: (parameter: number) => + parameter === (debugInfo ? UNMASKED_RENDERER_WEBGL : RENDERER) ? renderer : 'WebKit WebGL', + } as unknown as WebGL2RenderingContext; +} + +describe('whether WebGL draws on a GPU', () => { + it('takes a software rasterizer the caveat let through for none', () => { + // As Chromium names SwiftShader on a machine with no GPU. + const swiftShader = + 'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)'; + expect(rendersInSoftware(contextNaming(swiftShader))).toBe(true); + expect(rendersInSoftware(contextNaming('llvmpipe (LLVM 15.0.7, 256 bits)'))).toBe(true); + expect(rendersInSoftware(contextNaming('Microsoft Basic Render Driver', false))).toBe(true); + }); + + it('takes a GPU the renderer names for one', () => { + const metal = 'ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)'; + expect(rendersInSoftware(contextNaming(metal))).toBe(false); + expect(rendersInSoftware(contextNaming('NVIDIA GeForce RTX 4070/PCIe/SSE2', false))).toBe( + false, + ); + }); +}); diff --git a/packages/rito/tests/unit/reader-layout-config.test.ts b/packages/rito/tests/unit/reader-layout-config.test.ts index 37ad65bee..e77ae78aa 100644 --- a/packages/rito/tests/unit/reader-layout-config.test.ts +++ b/packages/rito/tests/unit/reader-layout-config.test.ts @@ -43,4 +43,19 @@ describe('reader createLayoutConfig', () => { expect(config.fontFamilyOverride).toBe('serif'); expect(config.fontFamilyForce).toBe(true); }); + + it('keeps a scrolled reader scrolled in any orientation, a window as wide as the viewport', () => { + for (const [width, height] of [ + [390, 844], + [1500, 950], + ] as const) { + const config = createLayoutConfig({ width, height, spread: 'scrolled', spreadGap: 20 }); + expect(config.spreadMode).toBe('scrolled'); + expect(config.pageWidth).toBe(width); + } + // A portrait viewport holds one page a spread. + expect(createLayoutConfig({ width: 390, height: 844, spread: 'double' }).spreadMode).toBe( + 'single', + ); + }); }); diff --git a/packages/rito/tests/unit/session-reader-fixtures.ts b/packages/rito/tests/unit/session-reader-fixtures.ts index 6fe96c60f..e8db371ca 100644 --- a/packages/rito/tests/unit/session-reader-fixtures.ts +++ b/packages/rito/tests/unit/session-reader-fixtures.ts @@ -482,9 +482,17 @@ export class FakeReaderSession { const start = revision.chapterIndex === undefined ? this.chapterStart(chapterIndex, revision.layout) : 0; const pageIndex = start + Math.round((query.locator.progression ?? 0) * (pages - 1)); + // A scrolled window says where the located line starts in it. + const scrolled = revision.layout.spreadMode === 'scrolled'; return { kind: 'location', - location: { kind: 'page', pageIndex, drawn: false, matchedBy: 'progression' }, + location: { + kind: 'page', + pageIndex, + drawn: false, + matchedBy: 'progression', + ...(scrolled ? { blockOffset: 42 } : {}), + }, }; } const whole = query.whole ?? true; @@ -494,9 +502,16 @@ export class FakeReaderSession { ? this.chapters.map((_, spineIndex) => spineIndex) : [revision.chapterIndex]; let start = 0; + const scrolled = revision.layout.spreadMode === 'scrolled'; const ranges = chapters.map((spineIndex) => { const pages = this.pagesOf(spineIndex, revision.layout); - const range = { spineIndex, startPage: start, endPage: start + pages - 1 }; + const range = { + spineIndex, + startPage: start, + endPage: start + pages - 1, + // A scrolled chapter's last window is half a viewport long. + ...(scrolled ? { extent: (pages - 0.5) * revision.layout.viewportHeight } : {}), + }; start += pages; return range; }); @@ -516,6 +531,7 @@ export class FakeReaderSession { // A table asked for in part stands for one whose chapter is laid // out only through the spread shown. complete: whole, + ...(scrolled ? { scrollAxis: 'down' as const } : {}), }, }; } @@ -551,7 +567,7 @@ export class FakeReaderSession { { pageIndex: page, width: this.pageWidth ?? revision.layout.viewportWidth, - height: revision.layout.viewportHeight, + height: this.windowHeight(revision, page), }, ], } as unknown as BrowserReaderArtifact; @@ -583,6 +599,22 @@ export class FakeReaderSession { return this.chapters.reduce((sum, _, index) => sum + this.pagesOf(index, revision.layout), 0); } + /** A page's height: a scrolled chapter's last window is half the viewport. */ + private windowHeight(revision: Revision, page: number): number { + const { layout } = revision; + if (layout.spreadMode !== 'scrolled') return layout.viewportHeight; + const chapters = + revision.chapterIndex === undefined + ? this.chapters.map((_, index) => index) + : [revision.chapterIndex]; + let end = 0; + for (const index of chapters) { + end += this.pagesOf(index, layout); + if (page === end - 1) return layout.viewportHeight / 2; + } + return layout.viewportHeight; + } + chapterStart(chapterIndex: number, layout: BrowserReaderArtifactRequest['layout']): number { let start = 0; for (let index = 0; index < chapterIndex; index += 1) start += this.pagesOf(index, layout); diff --git a/packages/rito/tests/unit/session-reader-host-fonts.test.ts b/packages/rito/tests/unit/session-reader-host-fonts.test.ts index 822cdb0bf..b4f01b18f 100644 --- a/packages/rito/tests/unit/session-reader-host-fonts.test.ts +++ b/packages/rito/tests/unit/session-reader-host-fonts.test.ts @@ -9,11 +9,14 @@ class FakeFontFace { // What a face reports until its descriptors are set. style = 'normal'; weight = 'normal'; + readonly featureSettings: string; constructor( readonly family: string, readonly source: unknown, + descriptors: { readonly featureSettings?: string } = {}, ) { + this.featureSettings = descriptors.featureSettings ?? 'normal'; FakeFontFace.created.push(this); } @@ -31,6 +34,7 @@ function fontRef(weight: number, style = 'normal') { weight, shapeFingerprint: 'shape', byteLength: BigInt(BYTES.byteLength), + features: [] as { tag: string; value: number }[], }; } @@ -89,6 +93,24 @@ describe('host faces of a session reader', () => { expect(registered.size).toBe(1); }); + it('registers a variant the engine names with features as a face of its own', async () => { + const fonts = new SessionReaderHostFonts(); + fonts.add([{ bytes: BYTES, expectedSha256: SHA }]); + + const face = await fonts.resolve(fontRef(400)); + const forms = await fonts.resolve({ + ...fontRef(400), + family: `__RitoVert___RitoPinned_${SHA}`, + features: [{ tag: 'vert', value: 1 }], + }); + + expect(forms).not.toBe(face); + expect(forms?.family).toBe(`__RitoVert___RitoPinned_${SHA}`); + expect((forms as unknown as FakeFontFace).featureSettings).toBe('"vert" 1'); + expect((face as unknown as FakeFontFace).featureSettings).toBe('normal'); + expect(registered.size).toBe(2); + }); + it('answers only for the faces it holds and forgets one that is removed', async () => { const fonts = new SessionReaderHostFonts(); fonts.add([{ bytes: BYTES, expectedSha256: SHA }]); diff --git a/packages/rito/tests/unit/session-reader-interactions.test.ts b/packages/rito/tests/unit/session-reader-interactions.test.ts index 559a0751c..b54593ea6 100644 --- a/packages/rito/tests/unit/session-reader-interactions.test.ts +++ b/packages/rito/tests/unit/session-reader-interactions.test.ts @@ -77,7 +77,8 @@ function sessionCaret(pageIndex: number, charIndex: number): SessionCaret { position: { blockIndex: 0, lineIndex: 0, runIndex: 0, charIndex }, affinity: 'downstream', }, - geometry: { x: MARGIN + 10 * charIndex, y: MARGIN + 20, height: 18 }, + geometry: { x: MARGIN + 10 * charIndex, y: MARGIN + 20, width: 0, height: 18 }, + writingMode: 'horizontal-tb', href: 'one.xhtml', sourcePoint: { nodePath: [0, 0], textOffset: BigInt(charIndex) }, }; @@ -306,7 +307,7 @@ describe('session reader interactions', () => { spreadIndex: 2, caret: { pageIndex: 2, - geometry: { x: 30, y: 20, height: 18 }, + geometry: { x: 30, y: 20, width: 0, height: 18 }, sourceLocator: { href: 'one.xhtml', sourcePoint: { nodePath: [0, 0], textOffset: 3 } }, }, }); diff --git a/packages/rito/tests/unit/session-reader.test.ts b/packages/rito/tests/unit/session-reader.test.ts index 7ada5e548..207181b96 100644 --- a/packages/rito/tests/unit/session-reader.test.ts +++ b/packages/rito/tests/unit/session-reader.test.ts @@ -43,6 +43,7 @@ vi.mock('../../src/bindings/browser/reader-session', async (importOriginal) => { weight: 400, shapeFingerprint: 'shape', byteLength: BigInt(policy?.faces[index]?.byteLength ?? 0), + features: [], })); harness.fake = fake; harness.openOptions = options; @@ -236,6 +237,47 @@ describe('Reader on a session', () => { await reader.dispose(); }); + it('lays a scrolled book out as windows end to end, and paints a window alone', async () => { + const { reader, fake } = await open({ + spread: 'scrolled', + initialLocator: { href: 'one.xhtml' }, + }); + await bookScope(reader); + await settle(); + expect(reader.getScrollLayout()).toEqual({ + axis: 'down', + windowLength: 600, + chapters: [ + { firstSpread: 0, spreadCount: 1, extent: 300 }, + { firstSpread: 1, spreadCount: 8, extent: 4500 }, + { firstSpread: 9, spreadCount: 5, extent: 2700 }, + ], + }); + const located = await reader.navigateToLocator?.({ href: 'two.xhtml', progression: 0.5 }); + expect(located).toMatchObject({ status: 'resolved', blockOffset: 42 }); + // The window read, its neighbours and the window after them are held. + const held = [...fake.live.values()].map((owned) => owned.artifact.localSpreadIndex); + expect(new Set(held)).toEqual(new Set([0, 1, 2, 3])); + + // The last window of the second chapter is half a viewport tall: it is + // painted into a target of its own size as into one of the viewport's. + harness.painted = []; + expect(reader.renderSpreadTo(8, fakeCanvasContext(800, 1200) as never)).toBe(false); + await settle(); + expect(reader.renderSpreadTo(8, fakeCanvasContext(800, 1200) as never)).toBe(true); + expect(reader.renderSpreadTo(8, fakeCanvasContext(800, 600) as never)).toBe(true); + expect(reader.renderSpreadTo(8, fakeCanvasContext(800, 900) as never)).toBe(false); + expect(harness.painted).toEqual(['8:#ffffff', '8:#ffffff']); + expect(fake.violations).toEqual([]); + await reader.dispose(); + }); + + it('has no scroll layout while it shows pages', async () => { + const { reader } = await open({ initialLocator: { href: 'one.xhtml' } }); + expect(reader.getScrollLayout()).toBeUndefined(); + await reader.dispose(); + }); + it('paints a loaded spread and loads one that is not, announcing its arrival', async () => { const { reader, ctx, invalidated } = await open({ initialLocator: { href: 'one.xhtml' } }); await bookScope(reader); diff --git a/packages/rito/vitest.config.ts b/packages/rito/vitest.config.ts index 610b3b0fd..d43534915 100644 --- a/packages/rito/vitest.config.ts +++ b/packages/rito/vitest.config.ts @@ -27,7 +27,12 @@ export default defineConfig({ }, { // What only a browser runs — WebGL2, OffscreenCanvas text — runs - // in headless Chromium, its coverage counted with the rest. + // in headless Chromium, its coverage counted with the rest. The full + // build, not the headless shell: the shell rasterizes canvas and + // page text on the CPU, blending glyphs otherwise than a GPU, while + // the full build rasterizes them on the GPU as Chrome does. No smart + // minimum font size: Chromium lifts em- and %-sized text to a minimum + // its UI language and platform choose, which CSS does not define. test: { name: 'browser', testTimeout: 30_000, @@ -35,7 +40,12 @@ export default defineConfig({ browser: { enabled: true, headless: true, - provider: playwright(), + provider: playwright({ + launchOptions: { + channel: 'chromium', + args: ['--blink-settings=minimumLogicalFontSize=0'], + }, + }), instances: [{ browser: 'chromium' }], }, }, diff --git a/packages/rito_flutter/CHANGELOG.md b/packages/rito_flutter/CHANGELOG.md index 43f828889..6e7827e17 100644 --- a/packages/rito_flutter/CHANGELOG.md +++ b/packages/rito_flutter/CHANGELOG.md @@ -1,3 +1,347 @@ +## Unreleased + +### Vertical text set as Chromium sets it + +A vertical-rl chapter's boxes take their margins, padding and borders on the +sides its flow puts them; each line hangs from its central baseline; each +upright glyph stands in its face's vertical forms at the font's vertical +origin and advances by its vertical advance (by the face's whole-pixel +height when the face has no vertical metrics), and Latin and digits in a +mixed line lie turned a quarter; ruby +annotations stand on their base where Chromium places them; an image +sizes to the column's length; and `text-combine-upright: all` text sets as +one horizontal string within an em of the column, in narrower forms or +scaled when it is wider than 1.1em; an inline box in another writing mode +lays out as an inline-block across the line; and a block's minimum and +maximum block size bound it. The artifact protocol is version 9: +`RitoFontRef.features` lists the OpenType features (`RitoFontFeature`) a +reference turns on. A reference with features is the same bytes as a face +under a family of its own (`vert`, the vertical forms; `hwid`, `twid`, +`qwid`, a combined string's narrower forms); the font cache +registers it like any face and the painter draws every run in that family +with those features on. + +### Emphasis marks + +`text-emphasis` (its longhands and their `-webkit-` and `-epub-` +spellings) paints as Chromium paints it: each character that takes a mark +gets one at half its size outside its em box, over or under the line (a +vertical line's right or left), and the marks move the lines around them +as Chromium's do. Marks arrive as annotation runs; nothing changes in the +protocol. + +### Scrolled reading + +A session asked for `RitoSpreadMode.scrolled` lays each chapter out as one +continuous strip read through windows the size of the viewport, one window +a spread, down the screen, or leftwards for a book whose pages progress +right to left (a chapter whose text runs across the book's axis is +paginated, each page one window). The revision layout says which way +(`scrollAxis`) and how long each chapter runs (`extent`), an artifact says +where its locator's line starts in its window (`targetBlockOffset`), and a +located page says the same (`blockOffset`). `RitoScrollReaderView` over a +`RitoScrollReaderController` shows the windows end to end in a scrollable +on the device grid, moved by any `ScrollPhysics`, keeps the windows near +the reading line loaded, makes the window under it the visible page, says +where the reader reads at rest (`position`) and what a tap is on +(`onTap`). `RitoScrollWindows` places the windows by the rule the engine +paints them by, each with the page it shows. An app selects with its own +gestures: `RitoScrollReaderViewState.pointAt` (through a `GlobalKey` on the +view) gives the window, page and display-list point under a view point, a +text interaction asked with `everyPage` answers across windows with each +window's rects in its own display list, and `windowOverlay` draws what the +app highlights over each window, moved with it. + +### Vertical pages are read where their columns are drawn + +A vertical-rl page's hits, outline, selection and range rects and carets +are in the page's drawn space, as a horizontal page's are: columns where +its lines are drawn, a point read back the same way, in a scrolled window +too. `RitoCaretGeometry.width` is new: a caret is the segment across its +line, `width` 0 on a horizontal line and `height` 0 down a vertical one. +`RitoCaret.writingMode` (`RitoWritingMode`) is the mode of the caret's +line; on `verticalRl` read an arrow key as the key a quarter turn from it +(down along the line, left to the next line), as a browser does. + +### Request IDs taken before the call + +`RitoReaderSession.takeRequestId()` holds the next request ID for the +caller, so calls issued back to back never share one; the page source takes +its IDs that way. + +### Line edges are decided as each line is broken + +A paragraph decides its line-end trims, the punctuation pairs a break +separates and its split ruby bases one line at a time, building itself at +most twice however many lines decide (once per decision before). A pair +trim that an earlier decision suppressed and a later one moved off its +break is applied again, as Chromium applies it: some CJK dialogue lines +now hold the character Chromium puts on them. + +### Line breaking fixes + +A `
` breaks the line under `line-break: anywhere` and after a preserved +space (both were laid out as one line). A paragraph shaped as a single run +longer than 64 KiB, as a volume written as one block in one font can be, +lays out instead of failing. A ligature is measured by its own glyphs in +every run, not only the paragraph's first. A line that gives the start of an +item to the next line (a ruby base that does not fit) keeps an +inline image that comes before that item, as Chromium does. An ideographic +space (or another space separator) that overflows a line hangs past its end +where the line may break after it, instead of sending the text before it to +the next line. A short ruby (a base of at most four glyphs under an +annotation of at most eight) moves to the next line whole instead of +breaking inside its base, as Chromium keeps it. + +### Lines break as Chromium breaks them in every language + +Each text item reads the ICU line rules its content language (`lang`) and +`line-break` value select, as Chromium's ICU does: Chinese text, and +Japanese and Korean text under `line-break: normal` or `loose`, reads the +curly double quotes as brackets and may start a line with 〜 or ゠; +`loose` also breaks before iteration marks and centered punctuation and +between inseparables; a quotation mark breaks only between East Asian +characters; a number keeps its prefix and postfix together only as a +number does. `word-break: break-all` and `keep-all` add and withhold +breaks per text item as Blink does, and `line-break: anywhere` applies +only to its own text. A line broken before spaces ends after them, and a +no-break space never hangs past the line's end. `-webkit-line-break` and +`-epub-word-break` (and `-epub-text-transform` and `-epub-caption-side`) +reach the engine as the properties Chromium aliases them to. + +### Images and inline-blocks aligned middle + +An image or inline-block styled `vertical-align: middle` centres on its +line as Chromium places it: its box moves by half its ascent less its +descent, less half its parent's x-height, so its middle sits half the +parent's x-height above the parent's baseline, down a vertical line too. +calibre's centred plate pages, a page-wide spacer and a full-height block +both aligned middle, now stand where Chromium draws them. + +### An inline box after a forced break measures on the next line + +An inline-block right after a forced break starts the next line when a box +measures its content's widest line, as Chromium measures it: a +shrink-to-fit table column holding `text
` takes the wider +of the two lines instead of their sum, and an inline box opening there +brings its padding along. + +### Block sizes sit on the layout grid + +A box's fixed, minimum and maximum block size sits on the LayoutUnit grid, +truncated as Chromium stores a used size: paragraphs held to +`min-width: 1.6em` down a vertical page stack 25.59375px apart instead of +25.6, so their excess no longer piles up over a page and moves where the +next page's lines start. + +### Images down a column snap like any inline image + +An image in a vertical line paints placed by its `object-fit` and snapped +to whole CSS pixels, as an image in a horizontal line does: a plate +centred down a column no longer lands a pixel off. + +### A block past the page end opens the next page + +A block whose start lands past the end of the page, after a line or plate +taller than the page, opens the next page however small it is, as +Chromium breaks before it: a page-break `
` after an over-wide plate +no longer hangs off the bottom of the plate's page. + +### Inline-blocks lay out as atomic inlines + +An element styled `display: inline-block` lays out as Chromium's atomic +inline whatever its tag: it takes its own `width` and `height` (a +percentage across a vertical line resolving against the page when the +line's own size is open, as Chromium's orthogonal fallback does), an empty +one still stands on its line at its size, and one holding blocks lays them +out inside it. calibre's centred-plate pages now land on the page Chromium +shows them on, with the plate centred across it. + +### Margins through nested wrappers collapse as one set + +Margins collapsing through several plain wrappers join as one set, the +largest positive plus the most negative, as CSS collapses adjoining +margins: a negative margin deep in a chain of wrappers no longer cancels +against only the nearest one, which left a title page's heading lower +than in the browser. + +### A missing image without alt keeps the icon + +A missing image with no `alt` attribute lays out as Chromium's 16px +broken-image icon, as one with alt text does; only an empty `alt` +collapses. A missing image in a short line no longer leaves every line +after it a pixel high. + +### A clearing block keeps its margin inside + +A block with content that clears the floats before it keeps its top margin +inside its container, as Chromium settles the container's position above a +block cleared past adjoining floats: floats opening a box no longer move +down by the margin of the paragraph that clears them. + +### A box that breaks across pages is sliced + +A box that breaks across pages paints as Chromium slices it: the fragment +that breaks before or inside a child (or between a paragraph's lines) +keeps the rest of the page, and no fragment strokes a border on an edge +the break cut. A bordered box whose illustration moves to the next page +draws its side borders down to the page bottom above it, with no bottom +border there and no top border where it resumes. Box fragments say which +block edges a break cut (`BlockCuts`). + +### A letterboxed image snaps where it is placed + +An image letterboxed by `object-fit: contain` is placed in its unsnapped box +and the placed rect then snaps to whole pixels, as Chromium snaps it: a thin +rule image scaled into a fractional box rasters on the one row the browser +draws it on instead of smearing across two. A raster placed under a pixel +tall or wide keeps one pixel where its start rounds, as the browser keeps +it, instead of arriving as an empty image the image cache rejects; only one +thinner than four LayoutUnits paints nothing. + +### A ligature paints as one cluster + +A ligature paints as one cluster from its start, and the cluster after it +joins when the whole ligature steps by its glyph's advance, so a pen +drawing clusters (the GPU pen) and a pen drawing strings both draw the +ligature the font forms: an embedded face's contextual pair of em dashes no +longer paints as two isolated dashes a pixel above the browser's joined +bar. Text fragments keep a cluster per character, which now says whether it +continues a ligature (`ClusterPosition::continues_ligature`), so selection +still reaches inside it. + +### Vertical text draws its decoration lines + +An underlined or overlined run in a vertical line (a link in a vertical +table of contents) draws its line down beside the column, where Chromium +draws it: Japanese and Korean text swaps the two lines, its underline on +the right and its overline on the left, where other text draws its +underline, a pixel off the em box. The display list gains a decoration line +command, lowered to a filled rect; the artifact protocol is unchanged. + +### Justified items end on the grid by their exact advance + +On a justified line, each inline item (a ruby pair, a styled span, the text +between them) ends at the 1/64px ceiling of its exact advance, its glyphs' +fixed-point advances plus its justification shares, as Chromium snaps an +item's width; a share that lifts an item only slightly past a grid point +no longer leaves it on the point below. + +### An illustration lends only the space under it to the ruby after it + +A ruby line after a line holding only an image (an illustration paragraph) +reaches only into the space under the image, as Chromium measures that +line's content by the image, instead of half the image line's height. + +### Vertical ruby text moves with its ruby box + +In a vertical line, the text of a ruby element moves by the rounding of +the element's start down the line to a whole CSS pixel, every base of one +`` with the first, as Chromium paints a box's text; plain text keeps +its fractional place, and ruby bases no longer sit a pixel off the +browser's. + +### Ruby bases align to a justified line's edges + +In a justified paragraph, a ruby whose annotation is wider than its base +sets its base against the line's start when it opens a line, and against +the line's end when it ends a justified line, with the annotation's spare +width on the other side, as Chromium aligns ruby at line edges; elsewhere +the base stays centered under its annotation. + +### Every bracket and quote trims as Chromium trims it + +Adjacent CJK punctuation trims by Chromium's classes for every bracket, +not only a fixed list: the CJK double prime quotes (`〝〞〟`) and any other +fullwidth bracket trim like `「」`, a fullwidth opener after a narrow one +(`(「`) and a fullwidth closer before a narrow one (`」)`) trim, the middle +dots `·` and `‧` make their neighbours trim as `・` does, and each of these +closers can trim at a line end. + +### A chapter takes only the stylesheets it links + +A chapter lays out under the stylesheets it links and embeds and no +others, as a browser applies them: a chapter without a `` no longer +takes every stylesheet the package lists. + +### An inline table moves to the next page whole + +An inline table (`display: inline-table`) moves to the next page whole +when it does not fit what is left of the page, as Chromium fragments an +atomic inline, instead of breaking between its rows like a block table. + +### A line's last glyph is measured unkerned + +A line's last glyph is measured as Chromium reshapes a line's end: where +the shaper marks the break after it unsafe (a kern pair with the space +that hangs after it, or with the next line's first character), the glyph +takes the advance it shapes to alone. A line that fits only with that +kerning breaks before the glyph, and a justified line spreads the slack +its unkerned end leaves, in Latin text as in CJK. + +### A no-break space justifies as a space + +A justified line spreads the gap after a no-break space with the other +word gaps, as Chromium treats the no-break space as a space when it +justifies, and a no-break space ending a justified line counts as content +instead of hanging. + +### Inline blocks stand on their margin box + +An inline block stands on its line by its margin box, as Chromium lays it +out: its margins, borders and padding surround its content, take their +room along the line and push its last line's baseline down, so the line it +rides grows by them. + +### Closers are trimmed across element boundaries + +A closing bracket or quote that overflows a line by half its width is +trimmed to keep it on the line when the line crosses an element boundary +and when the closer ends its element, as Chromium does since version 153. +Such a line no longer sends a whole note, or the run after an annotated +name, down to the next line, and a trimmed closer keeps the end padding of +the box it closes. + +### Inline box edges break as Chromium breaks them + +An inline element's start and end margin, border and padding take their +width as Chromium's open and close tags do. A line that breaks where the +element opens sends that start to the next line with the element's +content, instead of leaving it on the line above or pushing the character +before it down. Nested elements each add their own, an empty element takes +its width, vertical text reads the top and bottom sides, and percentage +padding resolves against the containing block. + +### Ruby reaches into the space above its line + +A paragraph's first-line ruby reaches into the space above the line before +the line grows, as Chromium lays it out: the collapsed margin above the +paragraph and the space under the previous paragraph's last line, not +across a top border or out of a paragraph that is its own formatting +context. A ruby line that opens a page takes its whole height there. + +### Ruby overhangs only on its own line + +A ruby whose annotation is wider than its base overhangs the text before it +only when that text is on its own line, as Chromium lays it out: a ruby +opening a line starts its annotation at the line's start. A justified, +centered or right-aligned line that ends where the next element's first +character did not fit aligns as Chromium aligns it, counting each ruby on it +at its full width, so such a line ending at a ruby is not stretched. + +### Word selection steps past spaces + +Extending a selection by a word moves to the end of the next word or the +start of the previous one past spaces and punctuation, as the browser does; +it stopped right after the space. + +### A long `
`-broken paragraph lays out as fast on a phone as on a desktop + +A chapter written as one block of `
`-separated lines no longer has its +text shaped again for every wrapped line that ends in a closing bracket or +quote before a `
`, which made such a chapter several times slower to lay +out at a phone's width; what is laid out is unchanged. + ## 0.9.0 - 2026-10-20 **Breaking:** requires Flutter 3.47, whose Flutter GPU samples a page image's diff --git a/packages/rito_flutter/lib/rito_flutter.dart b/packages/rito_flutter/lib/rito_flutter.dart index d7246ed6c..7093679d2 100644 --- a/packages/rito_flutter/lib/rito_flutter.dart +++ b/packages/rito_flutter/lib/rito_flutter.dart @@ -78,6 +78,7 @@ export 'src/protocol/revision_layout.dart' show RitoChapterPages, RitoRevisionLayout, + RitoScrollAxis, RitoSpreadPages, RitoTocTargetPage; export 'src/protocol/search.dart' @@ -114,7 +115,8 @@ export 'src/protocol/text_interaction.dart' RitoTextInteractionUnavailableReason, RitoTextPoint, RitoTextSelection, - RitoUnavailableResult; + RitoUnavailableResult, + RitoWritingMode; export 'src/reader/reader_controller.dart'; export 'src/reader_session.dart'; export 'src/render/font_envelope.dart' @@ -126,6 +128,9 @@ export 'src/render/page_surface.dart' RitoImageResolver, RitoPageSurface; export 'src/render/typed_color.dart' show ritoUiColor; +export 'src/scroll/scroll_reader_controller.dart'; +export 'src/scroll/scroll_reader_view.dart'; +export 'src/scroll/scroll_windows.dart'; export 'src/turn/effects.dart'; export 'src/turn/motion.dart' show diff --git a/packages/rito_flutter/lib/src/font/artifact_font_cache.dart b/packages/rito_flutter/lib/src/font/artifact_font_cache.dart index f8d2c3e8d..6bca509b5 100644 --- a/packages/rito_flutter/lib/src/font/artifact_font_cache.dart +++ b/packages/rito_flutter/lib/src/font/artifact_font_cache.dart @@ -279,6 +279,7 @@ final class RitoArtifactFontCache { resource.bytes, declaredWeight: font.weight, ); + RitoFontEnvelopeStore.shared.registerFeatures(font.family, font.features); } Future _waitFor( diff --git a/packages/rito_flutter/lib/src/protocol/artifact_decoder.dart b/packages/rito_flutter/lib/src/protocol/artifact_decoder.dart index 59b9b2a7c..55eef9e94 100644 --- a/packages/rito_flutter/lib/src/protocol/artifact_decoder.dart +++ b/packages/rito_flutter/lib/src/protocol/artifact_decoder.dart @@ -14,7 +14,7 @@ final class RitoArtifactDecoder { this.displayListDecoder = const RitoPrimitiveListDecoder(), }); - static const int protocolVersion = 7; + static const int protocolVersion = 9; static const int wireVersion = 1; static const int _maxSemanticDepth = 64; static final List _magic = ascii.encode('RITOART1'); @@ -94,6 +94,10 @@ final class RitoArtifactDecoder { 'book page count', () => reader.uint32('book page count'), ), + targetBlockOffset: reader.option( + 'target block offset', + () => reader.float64('target block offset'), + ), navigation: RitoNavigation( previous: _adjacentAvailability(reader), next: _adjacentAvailability(reader), diff --git a/packages/rito_flutter/lib/src/protocol/artifact_decoder_pages.dart b/packages/rito_flutter/lib/src/protocol/artifact_decoder_pages.dart index 5ab890217..b7273f45c 100644 --- a/packages/rito_flutter/lib/src/protocol/artifact_decoder_pages.dart +++ b/packages/rito_flutter/lib/src/protocol/artifact_decoder_pages.dart @@ -50,11 +50,23 @@ extension _ArtifactPages on RitoArtifactDecoder { weight: record.uint16('font weight'), shapeFingerprint: record.string('font shape fingerprint'), byteLength: record.uint64('font byte length'), + features: _fontFeatures(record), ); record.finish('font'); return result; } + List _fontFeatures(RitoBinaryReader record) { + final count = record.count('font features'); + return [ + for (var index = 0; index < count; index += 1) + RitoFontFeature( + tag: record.string('font feature tag'), + value: record.uint32('font feature value'), + ), + ]; + } + List _pages(RitoBinaryReader reader) { final count = reader.count('pages'); return [for (var index = 0; index < count; index += 1) _page(reader)]; diff --git a/packages/rito_flutter/lib/src/protocol/artifact_models.dart b/packages/rito_flutter/lib/src/protocol/artifact_models.dart index 39da7aa68..10827b57e 100644 --- a/packages/rito_flutter/lib/src/protocol/artifact_models.dart +++ b/packages/rito_flutter/lib/src/protocol/artifact_models.dart @@ -144,6 +144,7 @@ final class RitoFontRef { required this.weight, required this.shapeFingerprint, required this.byteLength, + this.features = const [], }); final String family; @@ -152,6 +153,19 @@ final class RitoFontRef { final int weight; final String shapeFingerprint; final int byteLength; + + /// OpenType features the face turns on wherever its family paints: the + /// same bytes registered again under a family of their own (the vertical + /// forms a vertical line's upright glyphs take). + final List features; +} + +/// One OpenType feature setting: its four-letter tag and value. +final class RitoFontFeature { + const RitoFontFeature({required this.tag, required this.value}); + + final String tag; + final int value; } /// What a hit is, as the engine classifies it: a note reference first @@ -306,6 +320,7 @@ final class RitoArtifact { required this.height, this.bookPageIndex, this.bookPageCount, + this.targetBlockOffset, required this.navigation, required this.textProfile, required this.displayList, @@ -351,6 +366,14 @@ final class RitoArtifact { /// background candidate already carries the total. Null on /// chapter-local artifacts. final int? bookPageCount; + + /// Where the line [locator] lands on starts in this scrolled window's + /// display list, in CSS pixels from the window's block start (its top, or + /// its right edge for a leftward strip): show the window from here to put + /// that line at the block start of the viewport's content box. Null for a + /// spread of pages, for a page shown as a window, and for a window whose + /// locator names no line in it. + final double? targetBlockOffset; final RitoNavigation navigation; final RitoTextProfile textProfile; final RitoDisplayListPayload displayList; diff --git a/packages/rito_flutter/lib/src/protocol/navigation_query.dart b/packages/rito_flutter/lib/src/protocol/navigation_query.dart index deef6085b..dc2eb4a4e 100644 --- a/packages/rito_flutter/lib/src/protocol/navigation_query.dart +++ b/packages/rito_flutter/lib/src/protocol/navigation_query.dart @@ -90,11 +90,17 @@ final class RitoLocatedPage extends RitoLocation { required this.pageIndex, required this.drawn, required this.matchedBy, + this.blockOffset, }); final int pageIndex; final bool drawn; final RitoLocatorMatch matchedBy; + + /// In a scrolled revision, where the line the locator lands on starts in + /// that page's window, as an artifact's target block offset; null for a + /// page, and for a locator that names no line. + final double? blockOffset; } /// The locator's chapter is not laid out in this revision. @@ -181,10 +187,15 @@ final class RitoNavigationDecoder { if (match >= RitoLocatorMatch.values.length) { reader.fail('unknown locator match: $match'); } + final blockOffset = reader.option( + 'located block offset', + () => reader.float64('located block offset'), + ); return RitoLocatedPage( pageIndex: pageIndex, drawn: drawn, matchedBy: RitoLocatorMatch.values[match], + blockOffset: blockOffset, ); case 1: return const RitoNotLaidOut(); diff --git a/packages/rito_flutter/lib/src/protocol/request_encoder.dart b/packages/rito_flutter/lib/src/protocol/request_encoder.dart index 3fc6062bc..ef9654649 100644 --- a/packages/rito_flutter/lib/src/protocol/request_encoder.dart +++ b/packages/rito_flutter/lib/src/protocol/request_encoder.dart @@ -107,6 +107,7 @@ final class RitoRequestEncoder { return switch (value) { RitoSpreadMode.single => 0, RitoSpreadMode.double => 1, + RitoSpreadMode.scrolled => 2, }; } diff --git a/packages/rito_flutter/lib/src/protocol/request_models.dart b/packages/rito_flutter/lib/src/protocol/request_models.dart index 6b970f5de..df96085ce 100644 --- a/packages/rito_flutter/lib/src/protocol/request_models.dart +++ b/packages/rito_flutter/lib/src/protocol/request_models.dart @@ -1,6 +1,22 @@ import 'artifact_models.dart'; -enum RitoSpreadMode { single, double } +enum RitoSpreadMode { + single, + double, + + /// Each chapter one continuous strip read through windows the size of + /// the viewport, every window its own spread. A book whose pages progress + /// right to left scrolls leftwards, others down (the revision layout's + /// scroll axis); a chapter whose blocks run across the book's axis is + /// paginated instead, each page one window. Window `k` of a strip covers + /// the chapter's CSS block range `[k·L, (k+1)·L)` from its block start + /// (`L` the viewport's height, or its width for a leftward strip; the last + /// window ends at the chapter's extent) and the device pixels + /// `[D(k), D(k+1))` along the axis, `D(k) = floor(k·L·ratio + 0.5)`, the + /// last ending at `floor(extent·ratio + 0.5)`: the windows tile the + /// chapter. A leftward strip starts at the right edge of its first window. + scrolled, +} final class RitoLayoutRequest { const RitoLayoutRequest({ diff --git a/packages/rito_flutter/lib/src/protocol/revision_layout.dart b/packages/rito_flutter/lib/src/protocol/revision_layout.dart index 1c1e47514..a2e4a6fe9 100644 --- a/packages/rito_flutter/lib/src/protocol/revision_layout.dart +++ b/packages/rito_flutter/lib/src/protocol/revision_layout.dart @@ -13,6 +13,7 @@ final class RitoRevisionLayout { required this.tocTargets, required this.activeTocEntryByPage, required this.complete, + this.scrollAxis, }); final int pageCount; @@ -34,6 +35,20 @@ final class RitoRevisionLayout { /// not holds their first pages, numbered and spread as all of them are; /// a TOC entry whose target lies past them is not placed yet. final bool complete; + + /// The direction a scrolled revision's chapters run in; null for a + /// revision of pages. + final RitoScrollAxis? scrollAxis; +} + +/// The direction a scrolled revision's chapters run: each chapter follows +/// the one before it along this axis. +enum RitoScrollAxis { + /// Down the screen, as horizontal text's blocks stack. + down, + + /// Leftwards, as vertical right-to-left text's columns follow. + left, } final class RitoSpreadPages { @@ -49,12 +64,17 @@ final class RitoChapterPages { required this.spineIndex, required this.startPage, required this.endPage, + this.extent, }); /// The item's index in the publication's spine. final int spineIndex; final int startPage; final int endPage; + + /// A scrolled chapter's extent along the scroll axis, in CSS pixels: its + /// margins and its strip. Null in a revision of pages. + final double? extent; } final class RitoTocTargetPage { @@ -87,6 +107,10 @@ RitoRevisionLayout readRitoRevisionLayout(RitoBinaryReader reader) { spineIndex: reader.uint32('chapter spine index'), startPage: reader.uint32('chapter start page'), endPage: reader.uint32('chapter end page'), + extent: reader.option( + 'chapter extent', + () => reader.float64('chapter extent'), + ), ), ), tocTargets: collection( @@ -101,5 +125,12 @@ RitoRevisionLayout readRitoRevisionLayout(RitoBinaryReader reader) { () => optional('page toc entry'), ), complete: reader.boolean('layout complete'), + scrollAxis: reader.option('scroll axis', () { + final tag = reader.uint8('scroll axis'); + if (tag >= RitoScrollAxis.values.length) { + reader.fail('unknown scroll axis tag: $tag'); + } + return RitoScrollAxis.values[tag]; + }), ); } diff --git a/packages/rito_flutter/lib/src/protocol/text_interaction.dart b/packages/rito_flutter/lib/src/protocol/text_interaction.dart index fd06fb413..4894f624f 100644 --- a/packages/rito_flutter/lib/src/protocol/text_interaction.dart +++ b/packages/rito_flutter/lib/src/protocol/text_interaction.dart @@ -39,23 +39,32 @@ final class RitoCaretAddress { final RitoCaretAffinity affinity; } -/// A caret's line in display-list space: its top and its height. +/// A caret in display-list space: the segment across its line, as a rect +/// with no extent along the line — [width] 0 on a horizontal line, +/// [height] 0 down a vertical one. final class RitoCaretGeometry { const RitoCaretGeometry({ required this.x, required this.y, + required this.width, required this.height, }); final double x; final double y; + final double width; final double height; } +/// The writing mode of the line a caret is on: which way its arrow keys +/// move it. +enum RitoWritingMode { horizontalTb, verticalRl } + final class RitoCaret { const RitoCaret({ required this.address, required this.geometry, + required this.writingMode, required this.href, required this.sourcePoint, }); @@ -64,6 +73,7 @@ final class RitoCaret { /// Present when the caret's page is one the artifact draws. final RitoCaretGeometry? geometry; + final RitoWritingMode writingMode; /// Canonical manifest href and source point the caret sits at. final String href; @@ -347,9 +357,11 @@ final class RitoTextInteractionDecoder { () => RitoCaretGeometry( x: record.float64('caret x'), y: record.float64('caret y'), + width: record.float64('caret width'), height: record.float64('caret height'), ), ), + writingMode: _tag(record, RitoWritingMode.values), href: record.string('caret href'), sourcePoint: _sourcePoint(record), ); diff --git a/packages/rito_flutter/lib/src/reader/page_source.dart b/packages/rito_flutter/lib/src/reader/page_source.dart index 29e47e9a1..70dd7e86c 100644 --- a/packages/rito_flutter/lib/src/reader/page_source.dart +++ b/packages/rito_flutter/lib/src/reader/page_source.dart @@ -54,7 +54,7 @@ final class RitoSessionPageSource implements RitoPageSource { RitoAdjacentDirection direction, ) => session.peek( from: from, - requestId: session.nextRequestId, + requestId: session.takeRequestId(), direction: direction, ); @@ -64,7 +64,7 @@ final class RitoSessionPageSource implements RitoPageSource { RitoAdjacentDirection direction, ) => session.turn( from: from, - requestId: session.nextRequestId, + requestId: session.takeRequestId(), direction: direction, ); @@ -75,7 +75,7 @@ final class RitoSessionPageSource implements RitoPageSource { ) => session.requestArtifact( RitoArtifactRequest( sessionId: session.sessionId, - requestId: session.nextRequestId, + requestId: session.takeRequestId(), layout: layout, locator: locator, ), diff --git a/packages/rito_flutter/lib/src/reader_session.dart b/packages/rito_flutter/lib/src/reader_session.dart index e500e988e..45402b486 100644 --- a/packages/rito_flutter/lib/src/reader_session.dart +++ b/packages/rito_flutter/lib/src/reader_session.dart @@ -3,6 +3,7 @@ // ignore_for_file: unnecessary_import import 'dart:async'; +import 'dart:math' as math; import 'dart:typed_data'; import 'font/artifact_font_cache.dart'; @@ -71,6 +72,7 @@ final class RitoReaderSession { required RitoArtifactResourcePreparer? resourcePreparer, }) : _sessionId = firstArtifact.sessionId, _latestRequestId = firstArtifact.requestId, + _takenRequestId = firstArtifact.requestId, _fontCache = fontCache, _imageCache = imageCache, _imagePixelRatio = imagePixelRatio, @@ -219,6 +221,10 @@ final class RitoReaderSession { final RitoReaderGateway gateway; final int _sessionId; int _latestRequestId; + + /// The highest request ID [takeRequestId] has handed out: a call taken + /// before an earlier one is sent still gets an ID of its own. + int _takenRequestId; int? _visibleArtifactId; int? _visibleRequestId; final RitoArtifactFontCache _fontCache; @@ -264,10 +270,20 @@ final class RitoReaderSession { /// The request ID callers should use for the next seek, reflow, or turn. int get nextRequestId { - if (_latestRequestId >= _maxRequestId) { + final latest = math.max(_latestRequestId, _takenRequestId); + if (latest >= _maxRequestId) { throw StateError('Rito reader request ID space is exhausted.'); } - return _latestRequestId + 1; + return latest + 1; + } + + /// [nextRequestId], held for the caller: the next call takes the one after + /// it even before this one reaches the engine, so calls issued back to + /// back never share an ID. + int takeRequestId() { + final requestId = nextRequestId; + _takenRequestId = requestId; + return requestId; } bool get isDisposed => _closing; diff --git a/packages/rito_flutter/lib/src/render/canvas_target_text.dart b/packages/rito_flutter/lib/src/render/canvas_target_text.dart index f19fc4882..0960bb636 100644 --- a/packages/rito_flutter/lib/src/render/canvas_target_text.dart +++ b/packages/rito_flutter/lib/src/render/canvas_target_text.dart @@ -129,6 +129,7 @@ extension _TextPainting on RitoPrimitiveCanvasTarget { ui.TextStyle( color: color, foreground: foreground, + fontFeatures: _fontFeatures(families), fontFamily: families.isEmpty ? null : families.first, fontFamilyFallback: families.length > 1 ? families.sublist(1) @@ -330,6 +331,7 @@ extension _TextPainting on RitoPrimitiveCanvasTarget { return TextStyle( color: foreground == null ? _effectiveTextColor(paint, runRect) : null, foreground: foreground, + fontFeatures: _fontFeatures(families), fontFamily: families.isEmpty ? null : families.first, fontFamilyFallback: families.length > 1 ? families.sublist(1) : null, fontSize: font.sizePx, @@ -340,6 +342,12 @@ extension _TextPainting on RitoPrimitiveCanvasTarget { ); } + /// The OpenType features the run's family paints with on: a face's + /// vertical forms are its bytes under a family of their own, registered + /// with `vert` on. + List? _fontFeatures(List families) => + families.isEmpty ? null : _fontEnvelopes?.featuresOf(families.first); + /// The weight to hand Flutter so its synthesis decision matches CSS. /// /// CSS matches a face by the `@font-face` descriptor; Flutter matches diff --git a/packages/rito_flutter/lib/src/render/font_envelope.dart b/packages/rito_flutter/lib/src/render/font_envelope.dart index ae4bffca3..cf961a66f 100644 --- a/packages/rito_flutter/lib/src/render/font_envelope.dart +++ b/packages/rito_flutter/lib/src/render/font_envelope.dart @@ -1,4 +1,7 @@ import 'dart:typed_data'; +import 'dart:ui' as ui; + +import '../protocol/artifact_models.dart' show RitoFontFeature; import 'font_family_stack.dart'; @@ -74,6 +77,25 @@ final class RitoFontEnvelopeStore { final Map _byFamily = {}; final Map _familyFaceCount = {}; + final Map> _featuresByFamily = + >{}; + + /// Records the OpenType features [family] paints with on: a face the + /// engine registers again under a family of its own (its vertical + /// forms), which the browser pen gets from the face's + /// `font-feature-settings` descriptor and this pen from each run's style. + void registerFeatures(String family, List features) { + if (features.isEmpty) { + return; + } + _featuresByFamily[family] = [ + for (final feature in features) + ui.FontFeature(feature.tag, feature.value), + ]; + } + + /// The OpenType features [family] paints with on, if any. + List? featuresOf(String family) => _featuresByFamily[family]; /// Registers a face's envelope for [family] from raw sfnt bytes /// (TTF or CFF-flavoured OTF). Non-sfnt payloads are ignored. diff --git a/packages/rito_flutter/lib/src/scroll/scroll_reader_controller.dart b/packages/rito_flutter/lib/src/scroll/scroll_reader_controller.dart new file mode 100644 index 000000000..fc9d40936 --- /dev/null +++ b/packages/rito_flutter/lib/src/scroll/scroll_reader_controller.dart @@ -0,0 +1,335 @@ +import 'dart:async'; +import 'dart:ui' show Offset; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +import '../font/artifact_font_cache.dart'; +import '../protocol/artifact_models.dart'; +import '../protocol/hit_resolver.dart'; +import '../protocol/request_models.dart'; +import '../protocol/revision_layout.dart'; +import '../protocol/text_interaction.dart'; +import '../reader/page_source.dart'; +import '../reader_session.dart'; +import 'scroll_windows.dart'; + +/// A window's artifact as the scroll view holds it: [committable] while the +/// session can still make it the visible page (it was peeked and not yet +/// committed). +final class _Held { + _Held(this.artifact, {required this.committable}); + + final RitoPreparedArtifact artifact; + bool committable; +} + +/// The state behind a [RitoScrollReaderView]: a scrolled revision's windows, +/// the artifacts of the ones near the reading line, and which window the +/// reader reads. +/// +/// It runs the session's work one operation at a time; it loads a window +/// when the view asks for it and lets go of the ones far from the reading +/// line; the window under the reading line becomes the session's visible +/// page; and it lays the rest of the book out in the background, taking +/// the whole book's windows when that layout catches up. Listeners hear +/// every change of [windows] and every window that lands. +/// +/// The controller does not own the session: dispose the controller, then +/// the session. +final class RitoScrollReaderController extends ChangeNotifier { + RitoScrollReaderController( + this.session, { + required RitoLayoutRequest layout, + required this.devicePixelRatio, + this.paginationQuantum = 4, + bool layOutBook = true, + }) : _layout = layout, + _visible = session.firstArtifact, + _reading = session.firstArtifact.artifact.localSpreadIndex, + _paginationComplete = !layOutBook { + unawaited(_enqueue(_takeLayout)); + unawaited( + SchedulerBinding.instance.endOfFrame.then((_) => _schedulePagination()), + ); + } + + final RitoReaderSession session; + + /// Device pixels a logical pixel of the screen the view draws on. + final double devicePixelRatio; + + /// The most pages a background pagination step lays out. + final int paginationQuantum; + + /// Windows further than this from the reading window are let go. + static const int reach = 2; + + final RitoLayoutRequest _layout; + RitoPreparedArtifact _visible; + RitoScrollWindows? _windows; + int _reading; + final Map _held = {}; + final Set _loading = {}; + Future _queue = Future.value(); + + /// Whether the book is laid out behind the reader: done, failed and + /// reported, or not asked for (`layOutBook: false` reads one chapter). + bool _paginationComplete; + bool _disposed = false; + + /// The windows of the layout the reader reads; null until it is read. + RitoScrollWindows? get windows => _windows; + + /// The window the reading line is in: the session's visible page. + int get readingSpread => _reading; + + /// The page on the session's screen. + RitoPreparedArtifact get visible => _visible; + + /// Where the reading line sits in the viewport, in device pixels: the + /// content box's block start. + double get readingLine => + (_windows?.axis == RitoScrollAxis.left + ? _layout.marginRight + : _layout.marginTop) * + devicePixelRatio; + + /// Where the reader reads, once the strip last came to rest: the line at + /// the reading line, else the window's own reading point. Null until the + /// strip first rests. + ValueListenable get position => _position; + final ValueNotifier _position = ValueNotifier( + null, + ); + + /// The strip came to rest at device offset [offset]: where the reader + /// reads now becomes [position]. + void settle(double offset) { + unawaited( + _enqueue(() async { + final locator = await _readingPosition(offset); + if (locator != null && !_disposed) _position.value = locator; + }), + ); + } + + /// The link, note or image at [point] of window [spreadIndex] (its + /// display-list space), if the window is loaded and there is one. + Future targetAt(int spreadIndex, Offset point) { + return _enqueue(() async { + final artifact = windowArtifact(spreadIndex); + if (artifact == null || _disposed) return null; + final records = await session.readPage( + artifact, + artifact.artifact.localPageIndex, + ); + return RitoHitResolver([ + records, + ]).resolve(x: point.dx, y: point.dy, linkSlack: 8); + }); + } + + Future _readingPosition(double offset) async { + final window = _windows?.windowAt(offset + readingLine); + if (window == null || _disposed) return null; + final artifact = windowArtifact(window.spreadIndex); + if (artifact == null) return null; + if (_windows?.axis == RitoScrollAxis.down) { + // The reading line, a pixel in from the content box's start. + final point = RitoTextPoint( + pageIndex: artifact.artifact.localPageIndex, + x: _layout.marginLeft + 1, + y: (offset + readingLine - window.start) / devicePixelRatio, + ); + final result = await session.textInteraction( + artifact, + RitoCaretQuery(point), + ); + if (result is RitoCaretResult) { + return RitoLocator( + href: result.caret.href, + sourcePoint: result.caret.sourcePoint, + ); + } + } + return artifact.artifact.locator; + } + + /// The artifact of window [spreadIndex]; null while it loads, and the + /// listeners hear when it lands. Only windows near the reading line load. + RitoPreparedArtifact? windowArtifact(int spreadIndex) { + if (spreadIndex == _visible.artifact.localSpreadIndex) return _visible; + final held = _held[spreadIndex]; + if (held != null) return held.artifact; + if ((spreadIndex - _reading).abs() <= reach && _loading.add(spreadIndex)) { + unawaited(_enqueue(() => _load(spreadIndex))); + } + return null; + } + + /// The strip is at device offset [offset]: the window under the reading + /// line becomes the visible page. + void read(double offset) { + final window = _windows?.windowAt(offset + readingLine); + if (window == null || window.spreadIndex == _reading) return; + _reading = window.spreadIndex; + unawaited(_enqueue(_commitReading)); + } + + Future _takeLayout() async { + if (_disposed) return; + // What is laid out so far: the chapter read first, or the whole book + // once its layout is taken, which holds every page from the start. + final layout = await session.revisionLayout(_visible, whole: false); + final windows = RitoScrollWindows.of( + layout, + windowLength: layout.scrollAxis == RitoScrollAxis.left + ? _layout.viewportWidth + : _layout.viewportHeight, + ratio: devicePixelRatio, + ); + if (windows == null || _disposed) return; + _windows = windows; + _reading = _visible.artifact.localSpreadIndex; + notifyListeners(); + } + + Future _load(int spreadIndex) async { + try { + if (_disposed || (spreadIndex - _reading).abs() > reach) return; + final peeked = await session.peekSpread( + from: _visible, + requestId: session.takeRequestId(), + spreadIndex: spreadIndex, + ); + _held[spreadIndex] = _Held(peeked, committable: true); + if (!_disposed) notifyListeners(); + } on Object catch (error, stack) { + _report(error, stack, 'loading a scrolled window'); + } finally { + _loading.remove(spreadIndex); + } + } + + /// Makes the reading window the visible page, and lets go of the windows + /// that are no longer near it. + Future _commitReading() async { + final target = _reading; + if (_disposed || target == _visible.artifact.localSpreadIndex) return; + try { + var held = _held[target]; + if (held == null || !held.committable) { + final peeked = await session.peekSpread( + from: _visible, + requestId: session.takeRequestId(), + spreadIndex: target, + ); + if (held != null) await _releaseQuietly(held.artifact); + held = _Held(peeked, committable: true); + } + _held.remove(target); + final previous = _visible; + _visible = await session.commitSpread( + from: previous, + spread: held.artifact, + ); + _held[previous.artifact.localSpreadIndex] = _Held( + previous, + committable: false, + ); + await _releaseFar(); + if (!_disposed) notifyListeners(); + } on Object catch (error, stack) { + _report(error, stack, 'reading a scrolled window'); + } + } + + Future _releaseFar() async { + final far = _held.entries + .where((entry) => (entry.key - _reading).abs() > reach) + .toList(); + for (final entry in far) { + _held.remove(entry.key); + await _releaseQuietly(entry.value.artifact); + } + } + + void _schedulePagination() { + if (_paginationComplete || _disposed) return; + // Each step waits for the isolate's next task, so the frames and input + // that came in meanwhile go first. + Timer(Duration.zero, () { + if (_paginationComplete || _disposed) return; + unawaited(_enqueue(_paginateOnce).then((_) => _schedulePagination())); + }); + } + + Future _paginateOnce() async { + if (_paginationComplete || _disposed) return; + try { + final step = await RitoSessionPageSource( + session, + ).paginate(_visible, paginationQuantum); + final adopted = step.adopted; + if (adopted != null) await _adopt(adopted); + if (step.complete) _paginationComplete = true; + } on Object catch (error, stack) { + // The book stays readable chapter by chapter without it. + _paginationComplete = true; + _report(error, stack, 'laying the book out in the background'); + } + } + + /// The whole book's layout caught up: its windows replace the chapter's, + /// and the windows held for the chapter are let go. + Future _adopt(RitoPreparedArtifact adopted) async { + final previous = _visible; + _visible = adopted; + final stale = _held.values.map((held) => held.artifact).toList(); + _held.clear(); + for (final artifact in [previous, ...stale]) { + if (!identical(artifact, adopted)) await _releaseQuietly(artifact); + } + await _takeLayout(); + } + + Future _releaseQuietly(RitoPreparedArtifact artifact) async { + try { + await session.releaseArtifact(artifact); + } on Object catch (error, stack) { + _report(error, stack, 'releasing a scrolled window'); + } + } + + Future _enqueue(Future Function() operation) { + final run = _queue.then((_) => operation()); + _queue = run.then((_) {}, onError: (Object _) {}); + return run; + } + + void _report(Object error, StackTrace stack, String context) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'rito_flutter', + context: ErrorDescription(context), + ), + ); + } + + /// Stops background layout and lets go of the held windows. The visible + /// page stays with the session. + @override + void dispose() { + _disposed = true; + _position.dispose(); + final held = _held.values.map((entry) => entry.artifact).toList(); + _held.clear(); + for (final artifact in held) { + unawaited(_enqueue(() => _releaseQuietly(artifact))); + } + super.dispose(); + } +} diff --git a/packages/rito_flutter/lib/src/scroll/scroll_reader_view.dart b/packages/rito_flutter/lib/src/scroll/scroll_reader_view.dart new file mode 100644 index 000000000..45dff8026 --- /dev/null +++ b/packages/rito_flutter/lib/src/scroll/scroll_reader_view.dart @@ -0,0 +1,299 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import '../protocol/hit_resolver.dart'; +import '../protocol/revision_layout.dart'; +import '../render/page_surface.dart'; +import 'scroll_reader_controller.dart'; +import 'scroll_windows.dart'; + +/// A point of a scrolled reader: its window, that window's page, and the +/// point in the window's display list — the space the window's records, +/// carets and selection rects are in. +final class RitoScrollPoint { + const RitoScrollPoint({ + required this.spreadIndex, + required this.pageIndex, + required this.point, + }); + + final int spreadIndex; + final int pageIndex; + final Offset point; +} + +/// A tap on a scrolled reader: the window, the point in its display list, +/// and the link, note or image there, if any. +final class RitoScrollTap { + const RitoScrollTap({ + required this.spreadIndex, + required this.point, + required this.target, + }); + + final int spreadIndex; + final Offset point; + final RitoTapTarget? target; +} + +/// What an app draws over window [window]: sized to it and moved with it, +/// in its display list's logical pixels — a selection's or a search's +/// rects of the window's page, as the session gives them. +typedef RitoScrollWindowOverlay = + Widget Function(BuildContext context, RitoScrollWindow window); + +/// A scrolled reader: the book's windows end to end, down the screen or +/// leftwards for a book that scrolls leftwards, in a scrollable that moves +/// by [physics] (the platform's by default) — an app gives its own physics, +/// or drives [scrollController] itself. +/// +/// The app draws over the windows with [windowOverlay] and arbitrates its +/// own gestures (a selection's long press and drag) through +/// [RitoScrollReaderViewState.pointAt], with a `GlobalKey` on the view. +final class RitoScrollReaderView extends StatefulWidget { + const RitoScrollReaderView({ + required this.controller, + this.physics, + this.scrollController, + this.colorOverride, + this.loadingColor = const Color(0xFFFFFFFF), + this.onTap, + this.windowOverlay, + super.key, + }); + + /// Called for a tap, with what is under it. + final ValueChanged? onTap; + + /// What the app draws over each window, if anything. + final RitoScrollWindowOverlay? windowOverlay; + + final RitoScrollReaderController controller; + final ScrollPhysics? physics; + + /// The strip's scroll position, in logical pixels from the book's block + /// start; the view makes its own when none is given. + final ScrollController? scrollController; + + /// Optional host theme (dark/sepia). See [RitoCanvasColorOverride]. + final RitoCanvasColorOverride? colorOverride; + + /// What a window shows while it loads. + final Color loadingColor; + + @override + State createState() => RitoScrollReaderViewState(); +} + +/// The state of a [RitoScrollReaderView], which says what is under a point. +final class RitoScrollReaderViewState extends State { + ScrollController? _owned; + RitoScrollWindows? _shown; + + ScrollController get _scroll => + widget.scrollController ?? (_owned ??= ScrollController()); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_changed); + _scroll.addListener(_moved); + _shown = widget.controller.windows; + } + + @override + void dispose() { + widget.controller.removeListener(_changed); + _scroll.removeListener(_moved); + _owned?.dispose(); + super.dispose(); + } + + void _moved() { + widget.controller.read(_scroll.offset * widget.controller.devicePixelRatio); + } + + /// New windows (the whole book's after the chapter's) keep the reading + /// line on the window it was in, as far into it as it was. + void _changed() { + final windows = widget.controller.windows; + final shown = _shown; + if (!identical(windows, shown) && windows != null) { + final ratio = widget.controller.devicePixelRatio; + final line = widget.controller.readingLine; + final before = _scroll.hasClients && shown != null + ? _scroll.offset * ratio + line + : null; + final into = before == null + ? 0.0 + : before - (shown!.windowAt(before)?.start ?? 0); + final reading = windows.windows.where( + (window) => window.spreadIndex == widget.controller.readingSpread, + ); + _shown = windows; + if (reading.isNotEmpty) { + final target = (reading.first.start + into - line) / ratio; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) + _scroll.jumpTo(target.clamp(0, double.infinity)); + }); + } + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final windows = widget.controller.windows; + if (windows == null) return ColoredBox(color: widget.loadingColor); + final ratio = windows.ratio; + final down = windows.axis == RitoScrollAxis.down; + final list = NotificationListener( + onNotification: (notification) { + widget.controller.settle(notification.metrics.pixels * ratio); + return false; + }, + child: _list(windows, ratio, down), + ); + final onTap = widget.onTap; + if (onTap == null) return list; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) => _tap(details, onTap), + child: list, + ); + } + + /// The window, its page and the point in the window's display list under + /// [localPosition] (logical pixels from the view's top-left corner); null + /// before the windows are shown or off them. + RitoScrollPoint? pointAt(Offset localPosition) { + final windows = widget.controller.windows; + final size = context.size; + if (windows == null || size == null || !_scroll.hasClients) return null; + final ratio = windows.ratio; + final down = windows.axis == RitoScrollAxis.down; + // How far along the strip the point is, in device pixels. + final along = down + ? (_scroll.offset + localPosition.dy) * ratio + : (_scroll.offset + size.width - localPosition.dx) * ratio; + final window = windows.windowAt(along); + if (window == null || + along < window.start || + along >= window.start + window.length) { + return null; + } + final into = (along - window.start) / ratio; + return RitoScrollPoint( + spreadIndex: window.spreadIndex, + pageIndex: window.pageIndex, + point: down + ? Offset(localPosition.dx, into) + : Offset(window.length / ratio - into, localPosition.dy), + ); + } + + /// The window and the point in it under a tap, and what is there. + void _tap(TapUpDetails details, ValueChanged onTap) { + final at = pointAt(details.localPosition); + if (at == null) return; + unawaited( + widget.controller.targetAt(at.spreadIndex, at.point).then((target) { + onTap( + RitoScrollTap( + spreadIndex: at.spreadIndex, + point: at.point, + target: target, + ), + ); + }), + ); + } + + Widget _list(RitoScrollWindows windows, double ratio, bool down) { + return CustomScrollView( + controller: _scroll, + physics: widget.physics, + scrollDirection: down ? Axis.vertical : Axis.horizontal, + reverse: !down, + slivers: [ + SliverVariedExtentList.builder( + itemCount: windows.windows.length, + itemExtentBuilder: (index, _) { + if (index >= windows.windows.length) return null; + return windows.windows[index].length / ratio; + }, + itemBuilder: (context, index) { + final window = windows.windows[index]; + final artifact = widget.controller.windowArtifact( + window.spreadIndex, + ); + final picture = artifact == null + ? ColoredBox(color: widget.loadingColor) + : ClipRect( + child: CustomPaint( + painter: RitoArtifactPainter( + artifact: artifact, + resolveImage: artifact.resolveImage, + colorOverride: widget.colorOverride, + ), + ), + ); + final overlay = widget.windowOverlay; + return _DeviceGridSnap( + ratio: ratio, + child: overlay == null + ? picture + : Stack( + fit: StackFit.expand, + children: [picture, overlay(context, window)], + ), + ); + }, + ), + ], + ); + } +} + +/// Paints its child on the device grid: windows are pictures resolved on +/// that grid, and the scroll position is anywhere between its pixels. +final class _DeviceGridSnap extends SingleChildRenderObjectWidget { + const _DeviceGridSnap({required this.ratio, super.child}); + + final double ratio; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderDeviceGridSnap(ratio); + + @override + void updateRenderObject( + BuildContext context, + _RenderDeviceGridSnap renderObject, + ) { + renderObject.ratio = ratio; + } +} + +final class _RenderDeviceGridSnap extends RenderProxyBox { + _RenderDeviceGridSnap(this._ratio); + + double _ratio; + + set ratio(double value) { + if (value == _ratio) return; + _ratio = value; + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + final child = this.child; + if (child == null) return; + double snap(double value) => (value * _ratio).roundToDouble() / _ratio; + context.paintChild(child, Offset(snap(offset.dx), snap(offset.dy))); + } +} diff --git a/packages/rito_flutter/lib/src/scroll/scroll_windows.dart b/packages/rito_flutter/lib/src/scroll/scroll_windows.dart new file mode 100644 index 000000000..c2ee15292 --- /dev/null +++ b/packages/rito_flutter/lib/src/scroll/scroll_windows.dart @@ -0,0 +1,98 @@ +import '../protocol/revision_layout.dart'; + +/// One window of a scrolled book on the device grid, along the scroll axis. +final class RitoScrollWindow { + const RitoScrollWindow({ + required this.spreadIndex, + required this.pageIndex, + required this.start, + required this.length, + }); + + final int spreadIndex; + + /// The page the window shows: a scrolled spread is one window. + final int pageIndex; + + /// Device pixels from the book's block start to the window's. + final int start; + + /// Its device pixels along the axis. + final int length; +} + +/// A scrolled book's windows end to end on the device grid. +/// +/// Within a chapter window `k` starts `D(k) = floor(k·L·ratio + 0.5)` +/// device pixels in, the rule the engine paints its windows by, and the +/// chapter ends at `floor(extent·ratio + 0.5)`; chapters follow one another +/// end to end. Offsets run from the book's block start: down the screen, +/// or leftwards from the right edge for a book that scrolls leftwards. +final class RitoScrollWindows { + RitoScrollWindows._(this.axis, this.ratio, this.windows, this.extent); + + /// The windows of [layout], a full one [windowLength] logical pixels + /// long, at [ratio] device pixels a logical one. Null for a revision of + /// pages. + static RitoScrollWindows? of( + RitoRevisionLayout layout, { + required double windowLength, + required double ratio, + }) { + final axis = layout.scrollAxis; + if (axis == null) return null; + final spreadOfPage = { + for (var index = 0; index < layout.spreads.length; index += 1) + layout.spreads[index].leftPageIndex: index, + }; + final windows = []; + var chapterStart = 0; + for (final chapter in layout.chapters) { + final extent = chapter.extent; + final first = spreadOfPage[chapter.startPage]; + if (extent == null || first == null) continue; + final count = chapter.endPage - chapter.startPage + 1; + final end = (extent * ratio + 0.5).floor(); + int row(int k) => + k >= count ? end : (k * windowLength * ratio + 0.5).floor(); + for (var k = 0; k < count; k += 1) { + windows.add( + RitoScrollWindow( + spreadIndex: first + k, + pageIndex: chapter.startPage + k, + start: chapterStart + row(k), + length: row(k + 1) - row(k), + ), + ); + } + chapterStart += end; + } + return RitoScrollWindows._(axis, ratio, windows, chapterStart); + } + + final RitoScrollAxis axis; + final double ratio; + + /// Every window, in reading order. + final List windows; + + /// The whole book's device pixels along the axis. + final int extent; + + /// The window holding device offset [offset]; the nearest end one outside + /// the book. + RitoScrollWindow? windowAt(double offset) { + if (windows.isEmpty) return null; + var low = 0; + var high = windows.length - 1; + while (low < high) { + final middle = (low + high + 1) >> 1; + if (windows[middle].start <= offset) { + low = middle; + } else { + high = middle - 1; + } + } + return windows[low]; + } +} diff --git a/packages/rito_flutter/test/artifact_wire_fixture_test.dart b/packages/rito_flutter/test/artifact_wire_fixture_test.dart index 7f16f5185..194a7520c 100644 --- a/packages/rito_flutter/test/artifact_wire_fixture_test.dart +++ b/packages/rito_flutter/test/artifact_wire_fixture_test.dart @@ -27,10 +27,17 @@ void main() { expect(artifact.localPageIndexes, [9, 10]); expect(artifact.bookPageIndex, 140); expect(artifact.bookPageCount, 312); + expect(artifact.targetBlockOffset, 377.640625); final page = artifact.pages.single; expect(page.pageIndex, 9); expect(page.width, 400.25); expect(page.height, 1194.25); + final font = artifact.fonts.single; + expect(font.family, 'Noto Serif CJK JP'); + expect(font.byteLength, (1 << 40) + 7); + final feature = font.features.single; + expect(feature.tag, 'vert'); + expect(feature.value, 1); }); test('decodes the page records the Rust encoder writes, every hit field', () { diff --git a/packages/rito_flutter/test/font_features_test.dart b/packages/rito_flutter/test/font_features_test.dart new file mode 100644 index 000000000..c143373d7 --- /dev/null +++ b/packages/rito_flutter/test/font_features_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rito_flutter/rito_flutter.dart'; + +/// A face's vertical forms: the same bytes the engine lists again under a +/// family of their own with `vert` on, which this pen turns on for every +/// run drawn in that family. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('a family registered with features paints with them on', () { + final store = RitoFontEnvelopeStore() + ..registerFeatures('forms', const [ + RitoFontFeature(tag: 'vert', value: 1), + ]) + ..registerFeatures('plain', const []); + + expect(store.featuresOf('forms'), [const ui.FontFeature('vert', 1)]); + expect(store.featuresOf('plain'), isNull); + expect(store.featuresOf('unknown'), isNull); + }); + + test('vert draws a bracket in its vertical form', () async { + final bytes = File( + '../../apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf', + ).readAsBytesSync(); + await (FontLoader( + 'VerticalFormsProbe', + )..addFont(Future.value(ByteData.sublistView(bytes)))).load(); + + Future> pixels(List features) async { + final builder = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: 40)) + ..pushStyle( + ui.TextStyle( + fontFamily: 'VerticalFormsProbe', + fontSize: 40, + fontFeatures: features, + color: const ui.Color(0xff000000), + ), + ) + ..addText('「'); + final paragraph = builder.build() + ..layout(const ui.ParagraphConstraints(width: 80)); + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder) + ..drawRect( + const ui.Rect.fromLTWH(0, 0, 80, 80), + ui.Paint()..color = const ui.Color(0xffffffff), + ) + ..drawParagraph(paragraph, ui.Offset.zero); + final picture = recorder.endRecording(); + final image = await picture.toImage(80, 80); + picture.dispose(); + final data = (await image.toByteData())!.buffer.asUint8List(); + image.dispose(); + return [for (var i = 0; i < data.length; i += 4) data[i]]; + } + + final plain = await pixels(const []); + final forms = await pixels(const [ui.FontFeature('vert', 1)]); + var differing = 0; + for (var i = 0; i < plain.length; i += 1) { + if (plain[i] != forms[i]) differing += 1; + } + expect(differing, greaterThan(100)); + }); +} diff --git a/packages/rito_flutter/test/native_revision_layout_test.dart b/packages/rito_flutter/test/native_revision_layout_test.dart index 00e46f722..b69016179 100644 --- a/packages/rito_flutter/test/native_revision_layout_test.dart +++ b/packages/rito_flutter/test/native_revision_layout_test.dart @@ -40,6 +40,29 @@ Future _open(int sessionId) => RitoReaderSession.open( ); void main() { + test('calls taken back to back each get a request id of their own', () async { + final session = await _open(9402); + try { + final first = session.firstArtifact; + final a = session.takeRequestId(); + final b = session.takeRequestId(); + expect(b, a + 1); + // Both reach the engine before either answers, and neither is + // refused as stale. + final peeked = await Future.wait(>[ + session.peekSpread(from: first, requestId: a, spreadIndex: 1), + session.peekSpread(from: first, requestId: b, spreadIndex: 2), + ]); + expect(peeked.map((page) => page.artifact.localSpreadIndex), [1, 2]); + expect(session.nextRequestId, b + 1); + for (final page in peeked) { + await session.releaseArtifact(page); + } + } finally { + await session.dispose(); + } + }); + test('a page reaches any spread its revision lays out', () async { final session = await _open(9401); try { diff --git a/packages/rito_flutter/test/navigation_wire_test.dart b/packages/rito_flutter/test/navigation_wire_test.dart index c9b6263b4..311fe681a 100644 --- a/packages/rito_flutter/test/navigation_wire_test.dart +++ b/packages/rito_flutter/test/navigation_wire_test.dart @@ -18,7 +18,7 @@ const String _compareHex = '00000000000000'; const String _locationHex = - '5249544f4e565231010000001c000000000000000100050000000102'; + '5249544f4e5652310100000025000000000000000100050000000102010000000000886a40'; const String _tocHex = '5249544f4e565231010000001a00000000000000000103000000'; @@ -67,6 +67,7 @@ void main() { expect(page.pageIndex, 5); expect(page.drawn, isTrue); expect(page.matchedBy, RitoLocatorMatch.anchor); + expect(page.blockOffset, 212.25); final toc = decoder.decode(_bytes(_tocHex)) as RitoTocEntryResult; expect(toc.tocId, 3); diff --git a/packages/rito_flutter/test/paint_parity_render_test.dart b/packages/rito_flutter/test/paint_parity_render_test.dart index 5fe2e82ad..782547894 100644 --- a/packages/rito_flutter/test/paint_parity_render_test.dart +++ b/packages/rito_flutter/test/paint_parity_render_test.dart @@ -85,6 +85,7 @@ Future _renderLoweredFixture(File meta, Directory outDir) async { final theme = json['theme'] as Map?; final target = RitoPrimitiveCanvasTarget( canvas, + ratio: list.ratio, resolveImage: (href) => images[href], // A `pending:` source is still on its way past its first second; any // other source no synthetic image answers can no longer arrive. diff --git a/packages/rito_flutter/test/revision_layout_wire_test.dart b/packages/rito_flutter/test/revision_layout_wire_test.dart index e4077ab06..7e6a1098a 100644 --- a/packages/rito_flutter/test/revision_layout_wire_test.dart +++ b/packages/rito_flutter/test/revision_layout_wire_test.dart @@ -119,11 +119,16 @@ void main() { <(int, int?)>[(0, null), (1, 2), (3, 4)], ); expect( - <(int, int, int)>[ + <(int, int, int, double?)>[ for (final chapter in layout.chapters) - (chapter.spineIndex, chapter.startPage, chapter.endPage), + ( + chapter.spineIndex, + chapter.startPage, + chapter.endPage, + chapter.extent, + ), ], - <(int, int, int)>[(1, 0, 2), (3, 3, 4)], + <(int, int, int, double?)>[(1, 0, 2, 4200.5), (3, 3, 4, 1900.25)], ); expect( <(int, int)>[ @@ -134,6 +139,7 @@ void main() { ); expect(layout.activeTocEntryByPage, [null, 2, 2, 6, 6]); expect(layout.complete, isFalse); + expect(layout.scrollAxis, RitoScrollAxis.left); }); test('a revision layout rejects truncation and trailing bytes', () { diff --git a/packages/rito_flutter/test/scroll_reader_view_test.dart b/packages/rito_flutter/test/scroll_reader_view_test.dart new file mode 100644 index 000000000..fd2f0e9ab --- /dev/null +++ b/packages/rito_flutter/test/scroll_reader_view_test.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rito_flutter/rito_flutter.dart'; + +const RitoLayoutRequest _layout = RitoLayoutRequest( + viewportWidth: 420, + viewportHeight: 640, + marginTop: 24, + marginRight: 24, + marginBottom: 24, + marginLeft: 24, + spreadMode: RitoSpreadMode.scrolled, + firstPageAlone: false, + spreadGap: 0, + rootFontSize: 16, +); + +Future _open(int sessionId) => RitoReaderSession.open( + gateway: RitoNativeGateway(), + publicationBytes: File( + '../rito/tests/fixtures/books/book-10.epub', + ).readAsBytesSync(), + request: RitoArtifactRequest( + sessionId: sessionId, + requestId: 1, + layout: _layout, + locator: const RitoLocator(href: 'OEBPS/Text/Section013.xhtml'), + ), + pinnedFontPolicy: RitoPinnedFontPolicy( + faces: [ + RitoPinnedFontFace( + bytes: File( + '../../apps/reader/src/assets/fonts/Tinos-Regular.ttf', + ).readAsBytesSync(), + genericRole: RitoPinnedFontGenericRole.serif, + ), + ], + ), +); + +Future _settle(WidgetTester tester) async { + for (var turn = 0; turn < 20; turn += 1) { + await tester.pump(); + } +} + +void main() { + testWidgets('a scrolled chapter reads on as its windows scroll by', ( + tester, + ) async { + final session = await _open(9951); + final controller = RitoScrollReaderController( + session, + layout: _layout, + devicePixelRatio: 1, + layOutBook: false, + ); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(420, 640); + addTearDown(tester.view.reset); + final taps = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RitoScrollReaderView(controller: controller, onTap: taps.add), + ), + ); + await _settle(tester); + final windows = controller.windows; + expect(windows, isNotNull); + expect(windows!.axis, RitoScrollAxis.down); + expect(windows.windows.length, greaterThan(2)); + expect(controller.readingSpread, 0); + expect( + controller.windowArtifact(1), + isNotNull, + reason: 'the next window loads', + ); + + // Past the reading line of the second window: it becomes the page read. + await tester.drag(find.byType(RitoScrollReaderView), const Offset(0, -700)); + await _settle(tester); + expect(controller.readingSpread, 1); + expect(controller.visible.artifact.localSpreadIndex, 1); + expect(session.visibleArtifactId, controller.visible.artifactId); + + // At rest, the reader reads the line at the reading line of that window. + final position = controller.position.value; + expect(position, isNotNull); + expect(position!.href, 'OEBPS/Text/Section013.xhtml'); + expect(position.sourcePoint, isNotNull); + + // A tap says which window it fell in and where. + await tester.tapAt(const Offset(200, 300)); + await _settle(tester); + expect(taps, hasLength(1)); + expect(taps.single.spreadIndex, 1); + expect(taps.single.point.dx, 200); + + controller.dispose(); + await _settle(tester); + await session.dispose(); + }); + + testWidgets( + 'an app selects across a seam through the view and draws it over each window', + (tester) async { + final session = await _open(9952); + final controller = RitoScrollReaderController( + session, + layout: _layout, + devicePixelRatio: 1, + layOutBook: false, + ); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(420, 640); + addTearDown(tester.view.reset); + final key = GlobalKey(); + final overlaid = {}; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RitoScrollReaderView( + key: key, + controller: controller, + windowOverlay: (context, window) { + overlaid.add(window.spreadIndex); + return const SizedBox.expand(); + }, + ), + ), + ); + await _settle(tester); + // The seam between the first two windows 240 px down the view. + await tester.drag( + find.byType(RitoScrollReaderView), + const Offset(0, -400), + ); + await _settle(tester); + expect(overlaid, containsAll([0, 1])); + final view = key.currentState!; + final from = view.pointAt(const Offset(100, 100))!; + final to = view.pointAt(const Offset(200, 400))!; + expect(from.spreadIndex, 0); + expect(from.point, const Offset(100, 500)); + expect(to.spreadIndex, 1); + expect(to.point, const Offset(200, 160)); + + RitoTextPoint at(RitoScrollPoint point) => RitoTextPoint( + pageIndex: point.pageIndex, + x: point.point.dx, + y: point.point.dy, + ); + final caret = await session.textInteraction( + controller.visible, + RitoCaretQuery(at(from)), + everyPage: true, + ); + final anchor = (caret as RitoCaretResult).caret; + expect(anchor.writingMode, RitoWritingMode.horizontalTb); + expect(anchor.geometry!.width, 0); + final range = await session.textInteraction( + controller.visible, + RitoRangeToPointQuery(anchor: anchor.address, focus: at(to)), + everyPage: true, + ); + final rects = (range as RitoSelectionResult).selection.rects; + // Each window's rects are in its own display list, where its overlay is. + expect(rects.map((rect) => rect.pageIndex).toSet(), { + from.pageIndex, + to.pageIndex, + }); + final second = rects.where((rect) => rect.pageIndex == to.pageIndex); + expect(second.first.bounds.y, lessThan(to.point.dy)); + + controller.dispose(); + await _settle(tester); + await session.dispose(); + }, + ); +} diff --git a/packages/rito_flutter/test/scroll_windows_test.dart b/packages/rito_flutter/test/scroll_windows_test.dart new file mode 100644 index 000000000..e22e2cc82 --- /dev/null +++ b/packages/rito_flutter/test/scroll_windows_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rito_flutter/src/protocol/revision_layout.dart'; +import 'package:rito_flutter/src/scroll/scroll_windows.dart'; + +RitoRevisionLayout _layout(RitoScrollAxis axis) => RitoRevisionLayout( + pageCount: 7, + spreads: [ + for (var page = 0; page < 7; page += 1) + RitoSpreadPages(leftPageIndex: page), + ], + chapters: const [ + RitoChapterPages(spineIndex: 0, startPage: 0, endPage: 0, extent: 300.5), + RitoChapterPages(spineIndex: 1, startPage: 1, endPage: 4, extent: 3401.25), + RitoChapterPages(spineIndex: 2, startPage: 5, endPage: 6, extent: 1900), + ], + tocTargets: const [], + activeTocEntryByPage: List.filled(7, null), + complete: true, + scrollAxis: axis, +); + +void main() { + test('windows tile each chapter by the engine rule, chapters end to end', () { + for (final ratio in [1, 1.25, 1.5, 2, 2.625, 3]) { + final windows = RitoScrollWindows.of( + _layout(RitoScrollAxis.down), + windowLength: 950, + ratio: ratio, + )!; + final all = windows.windows; + expect(all.map((window) => window.spreadIndex), [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + ]); + for (var index = 1; index < all.length; index += 1) { + expect(all[index].start, all[index - 1].start + all[index - 1].length); + } + expect(all[1].start, (300.5 * ratio + 0.5).floor()); + expect(all[2].start - all[1].start, (950 * ratio + 0.5).floor()); + expect( + windows.extent, + (300.5 * ratio + 0.5).floor() + + (3401.25 * ratio + 0.5).floor() + + (1900 * ratio + 0.5).floor(), + ); + } + }); + + test('finds the window under an offset, the end ones outside the book', () { + final windows = RitoScrollWindows.of( + _layout(RitoScrollAxis.left), + windowLength: 950, + ratio: 1, + )!; + expect(windows.axis, RitoScrollAxis.left); + expect(windows.windowAt(0)?.spreadIndex, 0); + expect(windows.windowAt(300)?.spreadIndex, 0); + expect(windows.windowAt(301)?.spreadIndex, 1); + expect(windows.windowAt(-10)?.spreadIndex, 0); + expect(windows.windowAt(1e9)?.spreadIndex, 6); + }); + + test('a revision of pages has no windows', () { + final paged = RitoRevisionLayout( + pageCount: 1, + spreads: const [RitoSpreadPages(leftPageIndex: 0)], + chapters: const [ + RitoChapterPages(spineIndex: 0, startPage: 0, endPage: 0), + ], + tocTargets: const [], + activeTocEntryByPage: const [null], + complete: true, + ); + expect(RitoScrollWindows.of(paged, windowLength: 950, ratio: 1), isNull); + }); +} diff --git a/packages/rito_flutter/test/support/artifact_fixture.dart b/packages/rito_flutter/test/support/artifact_fixture.dart index 0b29f4e55..243fa08b2 100644 --- a/packages/rito_flutter/test/support/artifact_fixture.dart +++ b/packages/rito_flutter/test/support/artifact_fixture.dart @@ -43,6 +43,7 @@ Uint8List artifactFixture({ ..float64(640) ..option(null) ..option(null) + ..option(null) ..uint32(previousAvailability) ..uint32(nextAvailability) ..uint32(0); @@ -138,7 +139,9 @@ void _fonts( ..string('normal') ..uint16(400) ..string(fingerprint) - ..uint64(byteLength); + ..uint64(byteLength) + // No feature settings: the face itself. + ..uint32(0); }); } diff --git a/packages/rito_flutter/test/text_interaction_wire_test.dart b/packages/rito_flutter/test/text_interaction_wire_test.dart index 225060530..9ae0f9b01 100644 --- a/packages/rito_flutter/test/text_interaction_wire_test.dart +++ b/packages/rito_flutter/test/text_interaction_wire_test.dart @@ -6,28 +6,11 @@ import 'package:rito_flutter/rito_flutter_protocol.dart'; import 'support/rust_fixture.dart'; -/// The request bytes are read from the Rust test that pins them -/// (`reader_session/interaction_tests.rs`). The response strings below -/// were produced by the Rust encoder from the fixtures in the same file; -/// regenerate them from Rust whenever a message changes shape. -const String _selectionHex = - '5249544f5449523101000000ee01000000000000090000000000000001016f00' - '0000000000001500000000000000030000000200000001000000000000000400' - '0000010100000000000029400000000000004440000000000000324015000000' - '4f454250532f636861707465722d322e7868746d6c1800000000000000030000' - '000100000000000000040000000c00000000000000003e010000000000001500' - '0000000000000300000002000000010000000000000004000000011500000000' - '0000000300000002000000010000000000000014000000001500000000000000' - '0300000002000000010000000000000004000000011500000000000000030000' - '000200000001000000000000001400000000100000007468652071756f746564' - '20776f726473150000004f454250532f636861707465722d322e7868746d6c18' - '00000000000000030000000100000000000000040000000c0000000000000015' - '0000004f454250532f636861707465722d322e7868746d6c1800000000000000' - '030000000100000000000000040000001c000000000000000100000038000000' - '0000000003000000000000000000294000000000000044400000000000105640' - '0000000000003240020000000100000000000000040000001400000001000000' - '0000205e40010000000000c47240'; - +/// The request bytes and the selection response are read from the Rust +/// test that pins them (`reader_session/interaction_tests.rs`). The +/// annotation strings below were produced by the Rust encoder from the +/// fixtures in the same file; regenerate them from Rust whenever a message +/// changes shape. const String _createHex = '5249544f414e5131010000007e00000000000000070000000000000000150000' '004f454250532f636861707465722d322e7868746d6c40000000000000001800' @@ -119,7 +102,7 @@ void main() { test('RITOTIR1 decodes a selection the engine encoded', () { final response = const RitoTextInteractionDecoder().decode( - _bytes(_selectionHex), + rustFixtureBytes('interaction_tests.rs', 'SELECTION_HEX'), ); expect(response.artifactId, 9); final result = response.result as RitoSelectionResult; @@ -127,7 +110,9 @@ void main() { expect(anchor.address.pageIndex, 3); expect(anchor.address.position.charIndex, 4); expect(anchor.address.affinity, RitoCaretAffinity.downstream); - expect(anchor.geometry!.height, 18.0); + expect(anchor.geometry!.width, 18.0); + expect(anchor.geometry!.height, 0.0); + expect(anchor.writingMode, RitoWritingMode.verticalRl); expect(anchor.href, 'OEBPS/chapter-2.xhtml'); expect(anchor.sourcePoint.nodePath, [1, 0, 4]); expect(result.focusCaret, isNull); @@ -141,7 +126,7 @@ void main() { test('RITOTIR1 rejects every truncated prefix and an unknown tag', () { const decoder = RitoTextInteractionDecoder(); - final bytes = _bytes(_selectionHex); + final bytes = rustFixtureBytes('interaction_tests.rs', 'SELECTION_HEX'); for (var end = 0; end < bytes.length; end += 1) { expect( () => decoder.decode(Uint8List.sublistView(bytes, 0, end)), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdcba4266..a827c4354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) '@playwright/test': - specifier: ^1.59.1 - version: 1.59.1 + specifier: ^1.64.1 + version: 1.64.1 '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -65,11 +65,11 @@ importers: specifier: ^8.58.0 version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2) vite: - specifier: ^6.4.3 - version: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + specifier: ^6.4.5 + version: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.2)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 6.1.1(typescript@6.0.2)(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) apps/reader: dependencies: @@ -132,11 +132,11 @@ importers: version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@playwright/test': - specifier: ^1.59.1 - version: 1.59.1 + specifier: ^1.64.1 + version: 1.64.1 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 4.2.2(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -148,13 +148,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.4.1 - version: 4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 4.7.0(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 vite: - specifier: ^6.4.3 - version: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + specifier: ^6.4.5 + version: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) packages/kit: dependencies: @@ -168,12 +168,15 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.12 version: 4.1.12(@vitest/browser@4.1.12)(vitest@4.1.12) + happy-dom: + specifier: ^21.0.4 + version: 21.0.4 tsdown: specifier: ^0.21.7 version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@6.0.2) vitest: specifier: ^4.1.12 - version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) packages/pack: devDependencies: @@ -191,7 +194,7 @@ importers: version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@6.0.2) vitest: specifier: ^4.1.12 - version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) packages/react: dependencies: @@ -211,6 +214,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.12 version: 4.1.12(@vitest/browser@4.1.12)(vitest@4.1.12) + happy-dom: + specifier: ^21.0.4 + version: 21.0.4 react: specifier: ^19.1.0 version: 19.2.4 @@ -222,7 +228,7 @@ importers: version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@6.0.2) vitest: specifier: ^4.1.12 - version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) packages/rito: devDependencies: @@ -234,7 +240,7 @@ importers: version: 25.5.0 '@vitest/browser-playwright': specifier: ^4.1.12 - version: 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(playwright@1.59.1)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) + version: 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(playwright@1.64.1)(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) '@vitest/coverage-v8': specifier: ^4.1.12 version: 4.1.12(@vitest/browser@4.1.12)(vitest@4.1.12) @@ -242,14 +248,14 @@ importers: specifier: ^0.8.2 version: 0.8.2 playwright: - specifier: ^1.59.1 - version: 1.59.1 + specifier: ^1.64.1 + version: 1.64.1 tsdown: specifier: ^0.21.7 version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(typescript@6.0.2) vitest: specifier: ^4.1.12 - version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + version: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) packages/rito-core-wasm: {} @@ -785,9 +791,9 @@ packages: '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} - '@playwright/test@1.59.1': - resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} - engines: {node: '>=18'} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} hasBin: true '@pnpm/deps.graph-sequencer@1100.0.1': @@ -1856,8 +1862,8 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} '@typescript-eslint/eslint-plugin@8.58.0': resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} @@ -2307,11 +2313,6 @@ packages: react-dom: optional: true - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2350,8 +2351,8 @@ packages: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - happy-dom@20.10.6: - resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + happy-dom@21.0.4: + resolution: {integrity: sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -2700,14 +2701,14 @@ packages: resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} hasBin: true - playwright-core@1.59.1: - resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} - engines: {node: '>=18'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} hasBin: true - playwright@1.59.1: - resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} - engines: {node: '>=18'} + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} hasBin: true pngjs@7.0.0: @@ -3125,8 +3126,8 @@ packages: peerDependencies: vite: '*' - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + vite@6.4.5: + resolution: {integrity: sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -3815,9 +3816,9 @@ snapshots: '@oxc-project/types@0.122.0': {} - '@playwright/test@1.59.1': + '@playwright/test@1.64.1': dependencies: - playwright: 1.59.1 + playwright: 1.64.1 '@pnpm/deps.graph-sequencer@1100.0.1': {} @@ -4766,12 +4767,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))': + '@tailwindcss/vite@4.2.2(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + vite: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) '@tybys/wasm-util@0.10.1': dependencies: @@ -4833,13 +4834,11 @@ snapshots: '@types/statuses@2.0.6': optional: true - '@types/whatwg-mimetype@3.0.2': - optional: true + '@types/whatwg-mimetype@3.0.2': {} - '@types/ws@8.18.1': + '@types/ws@8.18.2': dependencies: '@types/node': 25.5.0 - optional: true '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.1.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: @@ -4932,7 +4931,7 @@ snapshots: '@typescript-eslint/types': 8.58.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))': + '@vitejs/plugin-react@4.7.0(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.6) @@ -4940,33 +4939,33 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + vite: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(playwright@1.59.1)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12)': + '@vitest/browser-playwright@4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(playwright@1.64.1)(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12)': dependencies: - '@vitest/browser': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) - '@vitest/mocker': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) - playwright: 1.59.1 + '@vitest/browser': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) + '@vitest/mocker': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + playwright: 1.64.1 tinyrainbow: 3.1.0 - vitest: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + vitest: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12)': + '@vitest/browser@4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + '@vitest/mocker': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) '@vitest/utils': 4.1.12 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + vitest: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -4986,9 +4985,9 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + vitest: 4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) optionalDependencies: - '@vitest/browser': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) + '@vitest/browser': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) '@vitest/expect@4.1.12': dependencies: @@ -4999,14 +4998,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))': + '@vitest/mocker@4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@25.5.0)(typescript@6.0.2) - vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + vite: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) '@vitest/pretty-format@4.1.12': dependencies: @@ -5102,7 +5101,6 @@ snapshots: buffer-image-size@0.6.4: dependencies: '@types/node': 25.5.0 - optional: true cac@7.0.0: {} @@ -5200,8 +5198,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 - entities@7.0.1: - optional: true + entities@7.0.1: {} environment@1.1.0: {} @@ -5338,6 +5335,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -5365,9 +5366,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -5395,11 +5393,11 @@ snapshots: graphql@16.13.2: optional: true - happy-dom@20.10.6: + happy-dom@21.0.4: dependencies: '@types/node': 25.5.0 '@types/whatwg-mimetype': 3.0.2 - '@types/ws': 8.18.1 + '@types/ws': 8.18.2 buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 @@ -5407,7 +5405,6 @@ snapshots: transitivePeerDependencies: - bufferutil - utf-8-validate - optional: true has-flag@4.0.0: {} @@ -5709,13 +5706,11 @@ snapshots: dependencies: pngjs: 7.0.0 - playwright-core@1.59.1: {} + playwright-core@1.64.1: {} - playwright@1.59.1: + playwright@1.64.1: dependencies: - playwright-core: 1.59.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.1 pngjs@7.0.0: {} @@ -6165,21 +6160,21 @@ snapshots: - '@types/react' - '@types/react-dom' - vite-tsconfig-paths@6.1.1(typescript@6.0.2)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)): + vite-tsconfig-paths@6.1.1(typescript@6.0.2)(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.2) - vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + vite: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) transitivePeerDependencies: - supports-color - typescript - vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1): + vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 postcss: 8.5.26 rollup: 4.60.1 tinyglobby: 0.2.15 @@ -6190,10 +6185,10 @@ snapshots: lightningcss: 1.32.0 yaml: 2.9.1 - vitest@4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@20.10.6)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)): + vitest@4.1.12(@types/node@25.5.0)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(happy-dom@21.0.4)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.12 - '@vitest/mocker': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) + '@vitest/mocker': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.12 '@vitest/runner': 4.1.12 '@vitest/snapshot': 4.1.12 @@ -6210,18 +6205,17 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) + vite: 6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 - '@vitest/browser-playwright': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(playwright@1.59.1)(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) + '@vitest/browser-playwright': 4.1.12(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(playwright@1.64.1)(vite@6.4.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.1))(vitest@4.1.12) '@vitest/coverage-v8': 4.1.12(@vitest/browser@4.1.12)(vitest@4.1.12) - happy-dom: 20.10.6 + happy-dom: 21.0.4 transitivePeerDependencies: - msw - whatwg-mimetype@3.0.0: - optional: true + whatwg-mimetype@3.0.0: {} which@2.0.2: dependencies: diff --git a/tools/corpus-oracle/char-advance-probe.mjs b/tools/corpus-oracle/char-advance-probe.mjs index fa78bc9cd..27339a798 100644 --- a/tools/corpus-oracle/char-advance-probe.mjs +++ b/tools/corpus-oracle/char-advance-probe.mjs @@ -7,10 +7,7 @@ // // usage: node char-advance-probe.mjs [pIndex] [maxChars] import path from 'node:path'; -import { createRequire } from 'node:module'; -const { chromium } = createRequire(`${new URL('../..', import.meta.url).pathname}package.json`)( - '@playwright/test', -); +import { launchOracleChromium } from '../oracle-chromium.mjs'; const REPO = new URL('../..', import.meta.url).pathname; const PIN_LATIN = path.join(REPO, 'apps/reader/src/assets/fonts/Tinos-Regular.ttf'); @@ -24,7 +21,7 @@ if (!chapterFile) { process.exit(1); } -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const context = await browser.newContext({ viewport: { width: contentW + 200, height: contentH }, deviceScaleFactor: 1, diff --git a/tools/corpus-oracle/corpus-ab.mjs b/tools/corpus-oracle/corpus-ab.mjs index d0ce28483..f4a61ff31 100644 --- a/tools/corpus-oracle/corpus-ab.mjs +++ b/tools/corpus-oracle/corpus-ab.mjs @@ -5,6 +5,15 @@ // node tools/corpus-oracle/unpack-corpus.mjs ~/Downloads /tmp/rito-corpus // cargo build --release --example chapter-fragment-probe -p rito-core // RITO_ORACLE_DIR=/tmp/rito-corpus node tools/corpus-oracle/corpus-ab.mjs +// `RITO_FRAGMENT_PROBE=` runs a probe built elsewhere instead, so a +// run compares the engine it started with however `target/` is rebuilt. +// Narrowing a run to the paragraphs in question: `RITO_ORACLE_BOOKS` (comma +// separated file-name prefixes), `RITO_ORACLE_NEEDLE` (only the chapters +// whose source contains this text), `RITO_ORACLE_WIDTH` (content width, +// default 500), `RITO_ORACLE_ROOT_FONT` (the default font size on both +// sides, default 16), `RITO_ORACLE_MAX_LINES` (default 90) and +// `RITO_ORACLE_REPORT` (where the report goes). `RITO_ORACLE_DUMP=1` keeps +// both sides' lines (text, top) in each chapter's report entry. // Corpus A/B v2: rito fragment layout vs pinned-font Chromium, aligned by // line-break points instead of raw line index. // @@ -15,26 +24,34 @@ // and image-only lines are dropped, and scoring compares the break-point // sets of the concatenated text so one extra line never misaligns a // whole chapter. -import { createRequire } from 'node:module'; +import { launchOracleChromium } from '../oracle-chromium.mjs'; import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; -const { chromium } = createRequire(`${new URL('../..', import.meta.url).pathname}package.json`)( - '@playwright/test', -); const REPO = new URL('../..', import.meta.url).pathname; const DIR = `${process.env.RITO_ORACLE_DIR ?? '.'}/`; -const PROBE = `${REPO}target/release/examples/chapter-fragment-probe`; +const PROBE = + process.env.RITO_FRAGMENT_PROBE ?? `${REPO}target/release/examples/chapter-fragment-probe`; const SERIF = `${REPO}apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf`; const TINOS = `${REPO}apps/reader/src/assets/fonts/Tinos-Regular.ttf`; -const WIDTH = 500; -const MAX_LINES = 90; +const WIDTH = Number(process.env.RITO_ORACLE_WIDTH ?? 500); +const MAX_LINES = Number(process.env.RITO_ORACLE_MAX_LINES ?? 90); +const ROOT_FONT = Number(process.env.RITO_ORACLE_ROOT_FONT ?? 16); +const BOOKS = process.env.RITO_ORACLE_BOOKS?.split(','); +const NEEDLE = process.env.RITO_ORACLE_NEEDLE; +const DUMP = process.env.RITO_ORACLE_DUMP === '1'; const serifB64 = readFileSync(SERIF).toString('base64'); const tinosB64 = readFileSync(TINOS).toString('base64'); const manifest = JSON.parse(readFileSync(`${DIR}manifest.json`, 'utf8')); -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: WIDTH, height: 6000 } }); +const browser = await launchOracleChromium(); +const page = await browser.newPage({ viewport: { width: Math.ceil(WIDTH), height: 6000 } }); +// The browser's default font size is the engine's root font size. +await ( + await page.context().newCDPSession(page) +).send('Page.setFontSizes', { + fontSizes: { standard: ROOT_FONT, fixed: 13 }, +}); const normalize = (line) => line.replace(/\s+/g, ''); @@ -104,6 +121,7 @@ function lineLcs(a, b) { const results = []; for (const book of manifest) { + if (BOOKS && !BOOKS.some((prefix) => book.epub.split('/').pop().startsWith(prefix))) continue; const contentChapters = book.chapters.filter(([, path]) => { if (/cover|toc|nav|message|copyright/i.test(path)) return false; // Only chapters with real body text can score line breaks. @@ -117,10 +135,18 @@ for (const book of manifest) { return false; } }); - const picks = [0.3, 0.55, 0.8] - .map((f) => contentChapters[Math.floor(f * contentChapters.length)]) - .filter(Boolean) - .filter((c, i, all) => all.indexOf(c) === i); + const picks = NEEDLE + ? book.chapters.filter(([, path]) => { + try { + return readFileSync(path, 'utf8').includes(NEEDLE); + } catch { + return false; + } + }) + : [0.3, 0.55, 0.8] + .map((f) => contentChapters[Math.floor(f * contentChapters.length)]) + .filter(Boolean) + .filter((c, i, all) => all.indexOf(c) === i); if (picks.length === 0) continue; // Vertical-writing books are a known missing capability; annotate so @@ -155,6 +181,7 @@ for (const book of manifest) { fontPaths: [TINOS, SERIF], namedFonts: book.fonts, contentWidthPx: WIDTH, + rootFontSizePx: ROOT_FONT, chapterIdrefs: picks.map(([idref]) => idref), unfilteredFlow: true, }); @@ -189,6 +216,10 @@ for (const book of manifest) { reset.textContent = 'body { margin: 0; }'; document.head.prepend(reset); }); + // A fractional content width: the root box takes it exactly. + if (!Number.isInteger(WIDTH)) { + await page.addStyleTag({ content: `html { width: ${WIDTH}px; }` }); + } await page.evaluate( ({ serif, tinos }) => { const declared = new Set(); @@ -272,7 +303,8 @@ for (const book of manifest) { continue; } const score = scoreLines(ritoLines, browserLines.slice(0, MAX_LINES)); - bookResult.chapters.push({ idref, ...score }); + const lines = DUMP ? { ritoLines, browserLines: browserLines.slice(0, MAX_LINES) } : {}; + bookResult.chapters.push({ idref, ...score, ...lines }); } results.push(bookResult); const scored = bookResult.chapters.filter((c) => !c.error && c.lines > 0); @@ -287,6 +319,9 @@ for (const book of manifest) { `${String(pct).padStart(3)}%\t${tag}\teq${eq}/${bookResult.chapters.length}${dyNote}\t${book.epub.split('/').pop().slice(0, 44)}`, ); } -writeFileSync(`${DIR}corpus-ab-report.json`, JSON.stringify(results, null, 1)); +writeFileSync( + process.env.RITO_ORACLE_REPORT ?? `${DIR}corpus-ab-report.json`, + JSON.stringify(results, null, 1), +); await browser.close(); console.log('REPORT WRITTEN'); diff --git a/tools/corpus-oracle/geometry-check.mjs b/tools/corpus-oracle/geometry-check.mjs index b3c3370b0..55933420c 100644 --- a/tools/corpus-oracle/geometry-check.mjs +++ b/tools/corpus-oracle/geometry-check.mjs @@ -1,16 +1,13 @@ // What geometry is the reader actually using? Shell, canvas and committed layout. // usage: node geometry-check.mjs -import { createRequire } from 'node:module'; +import { launchOracleChromium } from '../oracle-chromium.mjs'; import path from 'node:path'; -const REPO = new URL('../..', import.meta.url).pathname; -const { chromium } = createRequire(`${REPO}package.json`)('@playwright/test'); - const [, , bookPath] = process.argv; const BASE = process.env.RITO_READER_URL ?? 'http://localhost:5173/'; const VIEWPORT = { width: 740, height: 950 }; -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: 1 }); await page.goto(BASE); let lastNav = Date.now(); diff --git a/tools/corpus-oracle/image-areas.mjs b/tools/corpus-oracle/image-areas.mjs new file mode 100644 index 000000000..8082683b7 --- /dev/null +++ b/tools/corpus-oracle/image-areas.mjs @@ -0,0 +1,110 @@ +// Where the truth page draws bitmap images, so a walk can count the +// beyond-floor pixels outside them apart. Canvas and DOM resample a scaled +// bitmap differently on the GPU (Δ up to ~53 per channel, no renderer can +// match the compositor's mip chain), so inside an image the diff measures +// the two rasterizers, not the engine. Each area is the image's DRAWN +// rectangle (object-fit / preserveAspectRatio applied) shrunk by one CSS +// pixel on every side: an image placed or sized wrongly still differs +// along its edges, which stay counted. + +/** + * The drawn image areas of a multicol truth chapter loaded in `page`, in + * column-local CSS px: `{ column, left, top, right, bottom }`. Columns step + * by `step` (column width plus gap) from the root's left edge. + */ +export async function collectImageAreas(page, { step }) { + // An svg `` has no natural size of its own: a detached bitmap of + // the same href loads to read it. Only its loading is awaited, polled + // from here (a page without JavaScript dispatches no load event to it): + // `decode()` would hand the page a whole decoded bitmap, and the next + // fresh paint (truth-shot.mjs) rasters the plate from it, not as a page + // that never asked rasters it (measured: 42k beyond-floor px on one + // 1266x1800 plate drawn at 0.47). + await page.evaluate(() => { + window.__ritoImageBitmaps = [...document.querySelectorAll('image')].map((image) => { + const href = image.getAttribute('href') ?? image.getAttribute('xlink:href'); + if (!href) return null; + const bitmap = new Image(); + bitmap.src = new URL(href, document.baseURI).href; + return bitmap; + }); + }); + for (let waited = 0; waited < 10000; waited += 50) { + if (await page.evaluate(() => window.__ritoImageBitmaps.every((b) => !b || b.complete))) break; + await page.waitForTimeout(50); + } + return page.evaluate(async (step) => { + const areas = []; + const push = (rect) => { + if (rect.width <= 2 || rect.height <= 2) return; + const column = Math.floor(rect.left / step); + const x = column * step; + areas.push({ + column, + left: rect.left - x + 1, + top: rect.top + 1, + right: rect.right - x - 1, + bottom: rect.bottom - 1, + }); + }; + // A bitmap of `natural` size fitted into `box` the way `fit` and the + // centred default position place it. + const fitted = (box, natural, fit) => { + if (!natural.width || !natural.height || fit === 'fill' || fit === 'cover') return box; + let scale = Math.min(box.width / natural.width, box.height / natural.height); + if (fit === 'none') scale = 1; + if (fit === 'scale-down') scale = Math.min(scale, 1); + const width = natural.width * scale; + const height = natural.height * scale; + const left = box.left + (box.width - width) / 2; + const top = box.top + (box.height - height) / 2; + return { left, top, right: left + width, bottom: top + height, width, height }; + }; + const contentBox = (el) => { + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + const n = (name) => parseFloat(cs.getPropertyValue(name)) || 0; + const left = r.left + n('border-left-width') + n('padding-left'); + const top = r.top + n('border-top-width') + n('padding-top'); + const right = r.right - n('border-right-width') - n('padding-right'); + const bottom = r.bottom - n('border-bottom-width') - n('padding-bottom'); + return { left, top, right, bottom, width: right - left, height: bottom - top }; + }; + for (const img of document.querySelectorAll('img')) { + if (!img.complete || !img.naturalWidth) continue; + const fit = getComputedStyle(img).objectFit; + push(fitted(contentBox(img), { width: img.naturalWidth, height: img.naturalHeight }, fit)); + } + for (const [index, image] of [...document.querySelectorAll('image')].entries()) { + const bitmap = window.__ritoImageBitmaps[index]; + if (!bitmap) continue; + const box = image.getBoundingClientRect(); + const ratio = (image.getAttribute('preserveAspectRatio') ?? '').trim(); + const fit = ratio === 'none' ? 'fill' : ratio.endsWith('slice') ? 'cover' : 'contain'; + push(fitted(box, { width: bitmap.naturalWidth, height: bitmap.naturalHeight }, fit)); + } + delete window.__ritoImageBitmaps; + for (const el of document.querySelectorAll('body, body *')) { + if (!getComputedStyle(el).backgroundImage.includes('url(')) continue; + for (const rect of el.getClientRects()) push(rect); + } + return areas; + }, step); +} + +/** + * A device-pixel mask of one page: 1 where a pixel lies inside an image + * area of `column`, the column placed at the content origin. + */ +export function imageMask(areas, column, { pageWd, pageHd, marginD, dsf }) { + const mask = new Uint8Array(pageWd * pageHd); + for (const area of areas) { + if (area.column !== column) continue; + const x0 = Math.max(0, Math.ceil(marginD + area.left * dsf)); + const x1 = Math.min(pageWd, Math.floor(marginD + area.right * dsf)); + const y0 = Math.max(0, Math.ceil(marginD + area.top * dsf)); + const y1 = Math.min(pageHd, Math.floor(marginD + area.bottom * dsf)); + for (let y = y0; y < y1; y += 1) mask.fill(1, y * pageWd + x0, y * pageWd + x1); + } + return mask; +} diff --git a/tools/corpus-oracle/image-cmd-dump.mjs b/tools/corpus-oracle/image-cmd-dump.mjs index 1b8521317..2e548de6c 100644 --- a/tools/corpus-oracle/image-cmd-dump.mjs +++ b/tools/corpus-oracle/image-cmd-dump.mjs @@ -3,12 +3,9 @@ // device pixels). // usage: node image-cmd-dump.mjs [spreadCount=8] // env RITO_READER_URL (default http://localhost:5173/) -import { createRequire } from 'node:module'; +import { launchOracleChromium } from '../oracle-chromium.mjs'; import path from 'node:path'; -const REPO = new URL('../..', import.meta.url).pathname; -const { chromium } = createRequire(`${REPO}package.json`)('@playwright/test'); - const [, , bookPath, spreadCountArg] = process.argv; if (!bookPath) { console.error('usage: node image-cmd-dump.mjs [spreadCount=8]'); @@ -17,7 +14,7 @@ if (!bookPath) { const SPREADS = Number(spreadCountArg ?? 8); const BASE = process.env.RITO_READER_URL ?? 'http://localhost:5173/'; -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const page = await browser.newPage({ viewport: { width: 1500, height: 950 }, deviceScaleFactor: 1, diff --git a/tools/corpus-oracle/melancholy-full-replica.mjs b/tools/corpus-oracle/melancholy-full-replica.mjs index c72de18cf..41c6a3663 100644 --- a/tools/corpus-oracle/melancholy-full-replica.mjs +++ b/tools/corpus-oracle/melancholy-full-replica.mjs @@ -1,18 +1,17 @@ // Full-fidelity replica of the b20 melancholy paragraph: exact ancestor // styles, multicol 640, pinned faces. Prints per-line text + the 。/」 // advances on the final line. -import { createRequire } from 'node:module'; +import { launchOracleChromium } from '../oracle-chromium.mjs'; import path from 'node:path'; const REPO = new URL('../..', import.meta.url).pathname; -const { chromium } = createRequire(`${REPO}package.json`)('@playwright/test'); const PIN_CJK = path.join(REPO, 'apps/reader/src/assets/fonts/SourceHanSerifCN-Regular.otf'); const PIN_LATIN = path.join(REPO, 'apps/reader/src/assets/fonts/Tinos-Regular.ttf'); const para = '「我聽說日文的名字大多不是只有音,而是有含意的。像小春就是spring day,古泉就是old spring。呵呵,真有趣。長門就是long gate了吧。可是我的名字寫成日文好像也不會有什麼意義。我不是不喜歡自己的名字,只是感覺很像古代悲劇裡會出現的人物,有點melancholy。」'; -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const page = await browser.newPage({ viewport: { width: 800, height: 950 }, deviceScaleFactor: 1 }); await page.setContent( `${paragraphs.join('')}`, +); +const browser = await launchOracleChromium(); +const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); +await page.goto(`file://${path.resolve(htmlPath)}`); +await page.evaluate(() => document.fonts.ready); +const loaded = await page.evaluate(() => document.fonts.check('16px "Pinned Serif"')); +if (!loaded) throw new Error('the pinned serif did not load: the truth would be a fallback font'); +const rects = await page.evaluate(() => { + const box = (rect) => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }); + return [...document.querySelectorAll('p')].map((p) => { + const node = p.firstChild; + const characters = []; + for (let offset = 0; offset < node.length; offset += 1) { + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + 1); + const caret = document.createRange(); + caret.setStart(node, offset); + caret.collapse(true); + characters.push({ + offset, + rects: [...range.getClientRects()].map(box), + caret: [...caret.getClientRects()].map(box), + }); + } + return { id: p.id, characters }; + }); +}); +writeFileSync(out, JSON.stringify({ width, height, margin, rects }, null, 1)); +console.log(`wrote ${rects.length} paragraphs to ${out}`); +await browser.close(); diff --git a/tools/corpus-oracle/walk-corpus.sh b/tools/corpus-oracle/walk-corpus.sh index 0c9e36dd4..b0dbc7fd3 100755 --- a/tools/corpus-oracle/walk-corpus.sh +++ b/tools/corpus-oracle/walk-corpus.sh @@ -5,7 +5,7 @@ # Usage: walk-corpus.sh [first-book-prefix] cd "$(dirname "$0")" || exit 1 corpus="$1"; ledger="$2"; from="${3:-000}" -[ -f "$ledger" ] || printf 'book\tpages\tzero\tbeyond_floor_px\tbeyond_floor_pages\ttotal_px\tworst_px\n' > "$ledger" +[ -f "$ledger" ] || printf 'book\tpages\tzero\tbeyond_floor_px\tbeyond_floor_pages\ttotal_px\tworst_px\toutside_images_px\toutside_images_pages\n' > "$ledger" for book in "$corpus"/*.epub; do name=$(basename "$book" .epub) prefix=$(printf '%s' "$name" | cut -c1-3) @@ -28,7 +28,7 @@ for book in "$corpus"/*.epub; do attempt=$((attempt + 1)) done if [ ! -f "$out/report.md" ]; then - printf '%s\tFAILED\t\t\t\t\t\n' "$prefix" >> "$ledger" + printf '%s\tFAILED\t\t\t\t\t\t\t\n' "$prefix" >> "$ledger" echo "FAILED $prefix" rm -rf "$out" continue @@ -36,9 +36,10 @@ for book in "$corpus"/*.epub; do report="$out/report.md" pages=$(sed -n 's/^pages compared: \([0-9]*\); at ZERO diff: \([0-9]*\); worst: \([0-9]*\) px.*/\1\t\2\t\3/p' "$report") floor=$(sed -n 's/^beyond the characterized raster floor (>13\/channel): \([0-9]*\) px on \([0-9]*\) pages.*/\1\t\2/p' "$report") + outside=$(sed -n 's/^beyond the floor outside drawn images: \([0-9]*\) px on \([0-9]*\) pages.*/\1\t\2/p' "$report") total=$(awk -F'/' '/^- /{gsub(/ /,"",$1); sub(/^-/,"",$1); s+=$1} END{print s+0}' "$report") p=$(printf '%s' "$pages" | cut -f1); z=$(printf '%s' "$pages" | cut -f2); w=$(printf '%s' "$pages" | cut -f3) - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$prefix" "$p" "$z" "$floor" "$total" "$w" >> "$ledger" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$prefix" "$p" "$z" "$floor" "$total" "$w" "$outside" >> "$ledger" echo "DONE $prefix pages=$p zero=$z total=$total" reports="${WALK_REPORTS:-walk-corpus-reports}" mkdir -p "$reports" diff --git a/tools/gl-pen-parity/run.mjs b/tools/gl-pen-parity/run.mjs index 64e2c734a..0405d0607 100644 --- a/tools/gl-pen-parity/run.mjs +++ b/tools/gl-pen-parity/run.mjs @@ -19,10 +19,11 @@ // that engine instead, headed with HEADED=1; each spread line says which // pen painted it, so a lane with no `gpu` line has proved nothing. import { createRequire } from 'node:module'; +import { launchOracleChromium } from '../oracle-chromium.mjs'; import { readFileSync } from 'node:fs'; import { launchUnthrottled } from '../resize-frames/shared.mjs'; -const { chromium, firefox, webkit } = createRequire( +const { firefox, webkit } = createRequire( new URL('../../apps/reader/package.json', import.meta.url), )('@playwright/test'); const { PNG } = createRequire(new URL('../../package.json', import.meta.url))('pngjs'); @@ -179,7 +180,7 @@ const browser = BROWSER !== 'chromium' ? await { firefox, webkit }[BROWSER].launch({ headless: process.env.HEADED !== '1' }) : process.env.GPU === '0' - ? await chromium.launch() + ? await launchOracleChromium() : await launchUnthrottled(); let failed = 0; let compared = 0; diff --git a/tools/oracle-chromium.mjs b/tools/oracle-chromium.mjs new file mode 100644 index 000000000..6c5f58f5e --- /dev/null +++ b/tools/oracle-chromium.mjs @@ -0,0 +1,40 @@ +// The Chromium every instrument measures against, the same on every machine. +import { createRequire } from 'node:module'; + +const { chromium } = createRequire(new URL('../package.json', import.meta.url))('@playwright/test'); + +/** + * The full build, not the headless shell: it rasterizes page and canvas + * text on the GPU as Chrome does, where the shell uses the CPU. No smart + * minimum font size: Chromium lifts em- and %-sized text to a minimum its + * UI language and platform choose (6px on Linux, 9px on macOS in English, + * 12px in Chinese), which CSS does not define and the engine never applies. + */ +export const ORACLE_CHROMIUM_ARGS = ['--blink-settings=minimumLogicalFontSize=0']; + +/** + * Launches the oracle Chromium with `args` added to its own (another build + * at `executablePath`, for an A/B run only). Its UI language + * sets the generic font families, accept-languages and the renderer's + * default locale; on macOS it follows the system unless the test browser + * has its own language, so a non-English one fails here instead of + * measuring a different truth. + */ +export async function launchOracleChromium(args = [], { executablePath } = {}) { + const browser = await chromium.launch({ + ...(executablePath ? { executablePath } : { channel: 'chromium' }), + args: [...ORACLE_CHROMIUM_ARGS, ...args], + }); + const context = await browser.newContext(); + const page = await context.newPage(); + const language = await page.evaluate(() => navigator.language); + await context.close(); + if (language !== 'en-US') { + await browser.close(); + throw new Error( + `The oracle Chromium runs as ${language}, not en-US. On macOS give the test browser ` + + 'its own language: defaults write com.google.chrome.for.testing AppleLanguages -array en-US', + ); + } + return browser; +} diff --git a/tools/paint-parity/.gitignore b/tools/paint-parity/.gitignore index 89f9ac04a..9e87b922e 100644 --- a/tools/paint-parity/.gitignore +++ b/tools/paint-parity/.gitignore @@ -1 +1,2 @@ out/ +out-seams/ diff --git a/tools/paint-parity/fixtures/text-vertical.json b/tools/paint-parity/fixtures/text-vertical.json index c278d5479..2b0b9c4c0 100644 --- a/tools/paint-parity/fixtures/text-vertical.json +++ b/tools/paint-parity/fixtures/text-vertical.json @@ -1,55 +1,16 @@ { "background": "#ffffff", "commands": [ - { - "kind": "pushState" - }, - { - "box": { - "height": 16, - "width": 16 - }, - "kind": "transform", - "origin": { - "x": 204, - "y": 28 - }, - "transforms": [ - { - "kind": "rotate", - "rad": 1.5707963267948966 - } - ] - }, - { - "clusters": [[0, 196.0, 32.8]], - "kind": "paintText", - "paint": { - "boxEnd": false, - "boxStart": false, - "color": "#000000", - "font": { - "family": "Tinos", - "sizePx": 16, - "style": "normal", - "weight": 400 - } - }, - "rect": { - "height": 16, - "width": 16, - "x": 196, - "y": 20 - }, - "text": "「" - }, - { - "kind": "popState" - }, { "clusters": [ - [0, 196.0, 48.8], - [3, 196.0, 64.8] + [0, 199.5, 34.08], + [3, 199.5, 50.08], + [6, 199.5, 66.08], + [9, 199.5, 82.08], + [12, 199.5, 98.08], + [15, 199.5, 114.08], + [18, 199.5, 130.08], + [21, 199.5, 146.08] ], "kind": "paintText", "paint": { @@ -64,89 +25,17 @@ } }, "rect": { - "height": 32, - "width": 16, - "x": 196, - "y": 36 - }, - "text": "春日" - }, - { - "kind": "pushState" - }, - { - "box": { - "height": 16, - "width": 16 - }, - "kind": "transform", - "origin": { - "x": 204, - "y": 76 - }, - "transforms": [ - { - "kind": "rotate", - "rad": 1.5707963267948966 - } - ] - }, - { - "clusters": [[0, 196.0, 80.8]], - "kind": "paintText", - "paint": { - "boxEnd": false, - "boxStart": false, - "color": "#000000", - "font": { - "family": "Tinos", - "sizePx": 16, - "style": "normal", - "weight": 400 - } - }, - "rect": { - "height": 16, + "height": 128, "width": 16, - "x": 196, - "y": 68 - }, - "text": "」" - }, - { - "kind": "popState" - }, - { - "clusters": [ - [0, 204.0, 87.2], - [3, 196.0, 112.8], - [6, 196.0, 128.8], - [9, 204.0, 135.20000000000002] - ], - "kind": "paintText", - "paint": { - "boxEnd": false, - "boxStart": false, - "color": "#000000", - "font": { - "family": "Tinos", - "sizePx": 16, - "style": "normal", - "weight": 400 - } - }, - "rect": { - "height": 64, - "width": 16, - "x": 196, - "y": 84 + "x": 199.5, + "y": 20 }, - "text": "、剧场。" + "text": "「春日」、剧场。" }, { "clusters": [ - [0, 196.0, 160.8], - [3, 196.0, 176.8] + [0, 199.5, 162.08], + [3, 199.5, 178.08] ], "kind": "paintText", "paint": { @@ -163,16 +52,16 @@ "rect": { "height": 32, "width": 16, - "x": 196, + "x": 199.5, "y": 148 }, "text": "漢字" }, { "clusters": [ - [0, 212.0, 155.42708333333334], - [3, 212.0, 166.09375], - [6, 212.0, 176.76041666666669] + [0, 219.5, 156.37333333333333], + [3, 219.5, 167.04], + [6, 219.5, 177.70666666666668] ], "kind": "paintRuby", "paint": { @@ -187,33 +76,16 @@ "rect": { "height": 32, "width": 8, - "x": 212, + "x": 219.5, "y": 148 }, "text": "かんじ" }, { - "kind": "pushState" - }, - { - "box": { - "height": 16, - "width": 16 - }, - "kind": "transform", - "origin": { - "x": 178, - "y": 28 - }, - "transforms": [ - { - "kind": "rotate", - "rad": 1.5707963267948966 - } - ] - }, - { - "clusters": [[0, 170.0, 32.8]], + "clusters": [ + [0, 167.5, 34.08], + [3, 167.5, 52.08] + ], "kind": "paintText", "paint": { "boxEnd": false, @@ -228,15 +100,12 @@ "letterSpacingPx": 2 }, "rect": { - "height": 16, + "height": 36, "width": 16, - "x": 170, + "x": 167.5, "y": 20 }, - "text": "…" - }, - { - "kind": "popState" + "text": "…—" }, { "kind": "pushState" @@ -244,12 +113,12 @@ { "box": { "height": 16, - "width": 16 + "width": 36 }, "kind": "transform", "origin": { - "x": 178, - "y": 46 + "x": 170, + "y": 56 }, "transforms": [ { @@ -259,7 +128,10 @@ ] }, { - "clusters": [[0, 170.0, 50.8]], + "clusters": [ + [0, 170, 56], + [1, 188, 56] + ], "kind": "paintText", "paint": { "boxEnd": false, @@ -275,40 +147,14 @@ }, "rect": { "height": 16, - "width": 16, + "width": 36, "x": 170, - "y": 38 + "y": 43.2 }, - "text": "—" + "text": "ab" }, { "kind": "popState" - }, - { - "clusters": [ - [0, 170.0, 68.8], - [1, 170.0, 86.8] - ], - "kind": "paintText", - "paint": { - "boxEnd": false, - "boxStart": false, - "color": "#000080", - "font": { - "family": "Tinos", - "sizePx": 16, - "style": "normal", - "weight": 400 - }, - "letterSpacingPx": 2 - }, - "rect": { - "height": 36, - "width": 16, - "x": 170, - "y": 56 - }, - "text": "ab" } ], "height": 320, diff --git a/tools/paint-parity/render-browser.mjs b/tools/paint-parity/render-browser.mjs index d325a0875..cc0af47cb 100644 --- a/tools/paint-parity/render-browser.mjs +++ b/tools/paint-parity/render-browser.mjs @@ -16,6 +16,7 @@ // loaded before painting — an unloaded face silently falls back and // invalidates every text fixture. import { createRequire } from 'node:module'; +import { launchOracleChromium } from '../oracle-chromium.mjs'; import { pathToFileURL } from 'node:url'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -23,7 +24,6 @@ import path from 'node:path'; const REPO = new URL('../..', import.meta.url).pathname; const HERE = new URL('.', import.meta.url).pathname; const requireRepo = createRequire(`${REPO}package.json`); -const { chromium } = requireRepo('@playwright/test'); const vite = await import(pathToFileURL(requireRepo.resolve('vite'))); const { launchUnthrottled } = await import('../resize-frames/shared.mjs'); @@ -62,7 +62,7 @@ const FONTS = [ // pen where WebGL has no GPU); the GPU pass is a reader's browser, where // the GPU pen draws text and fills itself and is held to that browser's // canvas pen. -await renderPass(await chromium.launch(), { +await renderPass(await launchOracleChromium(), { flat: 'browser', offscreen: 'browser-offscreen', gpu: 'browser-gl-software', diff --git a/tools/paint-parity/seam-diff.mjs b/tools/paint-parity/seam-diff.mjs new file mode 100644 index 000000000..9cdeb9665 --- /dev/null +++ b/tools/paint-parity/seam-diff.mjs @@ -0,0 +1,127 @@ +// Stacks each pen's render of a scrolled window on its render of the next +// window (beside it, the next on the left, for windows that run leftwards) +// and diffs the stack against the same pen's render of the pair painted as +// one run (`seam--…-a`, `-b`, `-ab`, written by the engine's seam +// lowering, whose metadata names the axis). That a window draws what the pair draws is the +// engine's own test (`a_window_draws_its_content_as_the_pair_…`); what +// this reports is how the pens raster the two: a canvas picks its +// antialiasing mode from everything a frame holds, and a scaled image cut +// by the window edge samples only what is visible, so a window and the +// pair can raster an edge a few levels apart, now and then a pixel past +// the floor. Writes /seam-report.md and a red-overlay diff PNG +// per divergent pair; exits 1 only when the stack is not the pair's size +// or a render is missing. +// +// node tools/paint-parity/seam-diff.mjs [outRoot] +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const REPO = new URL('../..', import.meta.url).pathname; +const HERE = new URL('.', import.meta.url).pathname; +const { PNG } = createRequire(`${REPO}package.json`)('pngjs'); + +const outRoot = process.argv[2] ?? path.join(HERE, 'out-seams'); +/** The walk's raster floor: the report counts the pixels past it apart. */ +const FLOOR = 13; +const LANES = [ + 'browser', + 'browser-offscreen', + 'browser-gl-software', + 'browser-gpu', + 'browser-gl', + 'flutter', +]; + +const pairs = readdirSync(path.join(outRoot, 'lowered')) + .filter((file) => file.startsWith('seam-') && file.endsWith('-ab.json')) + .map((file) => file.slice(0, -'-ab.json'.length)) + .sort(); + +const read = (dir, name) => { + const file = path.join(outRoot, dir, `${name}.png`); + return existsSync(file) ? PNG.sync.read(readFileSync(file)) : undefined; +}; + +const axisOf = (pair) => + JSON.parse(readFileSync(path.join(outRoot, 'lowered', `${pair}-ab.json`), 'utf8')).axis ?? 'down'; + +/** + * Pixels the stacked windows and the pair differ in, and those beyond the + * floor. `first` is the window the run starts with: on top, or on the right + * for windows that run leftwards. + */ +function stackDiff(first, second, pair, axis) { + const left = axis === 'left'; + const [across, along] = left ? ['height', 'width'] : ['width', 'height']; + if (first[across] !== pair[across] || second[across] !== pair[across]) { + return { error: `${across}s ${first[across]}/${second[across]} vs ${pair[across]}` }; + } + if (first[along] + second[along] !== pair[along]) { + return { error: `${along}s ${first[along]}+${second[along]} vs ${pair[along]}` }; + } + // The window a pair pixel comes from, and where in it. + const source = (x, y) => { + if (!left) return y < first.height ? [first, x, y] : [second, x, y - first.height]; + return x < second.width ? [second, x, y] : [first, x - second.width, y]; + }; + const overlay = new PNG({ width: pair.width, height: pair.height }); + let count = 0; + let beyond = 0; + for (let y = 0; y < pair.height; y += 1) { + for (let x = 0; x < pair.width; x += 1) { + const [window, wx, wy] = source(x, y); + const at = (y * pair.width + x) * 4; + const from = (wy * window.width + wx) * 4; + let delta = 0; + for (let c = 0; c < 4; c += 1) { + delta = Math.max(delta, Math.abs(window.data[from + c] - pair.data[at + c])); + } + const differs = delta > 0; + if (differs) count += 1; + if (delta > FLOOR) beyond += 1; + for (let c = 0; c < 3; c += 1) { + overlay.data[at + c] = differs ? [255, 0, 0][c] : 192 + (pair.data[at + c] >> 2); + } + overlay.data[at + 3] = 255; + } + } + return { count, beyond, overlay }; +} + +const lines = [ + '# Scroll seams: stacked windows vs the pair painted as one', + '', + `Pixels a stacked pair differs in, and those beyond the raster floor (>${FLOOR}/channel).`, + '', +]; +let failures = 0; +for (const lane of LANES) { + if (!existsSync(path.join(outRoot, lane))) continue; + const diffDir = path.join(outRoot, `seam-diff-${lane}`); + mkdirSync(diffDir, { recursive: true }); + const bad = []; + const raster = []; + for (const pair of pairs) { + const [first, second, whole] = ['a', 'b', 'ab'].map((part) => read(lane, `${pair}-${part}`)); + if (!first || !second || !whole) { + bad.push(`${pair}: missing render`); + continue; + } + const result = stackDiff(first, second, whole, axisOf(pair)); + if (result.error) { + bad.push(`${pair}: ${result.error}`); + } else if (result.count > 0) { + raster.push(`${pair}: ${result.count} px, ${result.beyond} beyond the floor`); + writeFileSync(path.join(diffDir, `${pair}.png`), PNG.sync.write(result.overlay)); + } + } + failures += bad.length; + const identical = pairs.length - bad.length - raster.length; + lines.push(`## ${lane}: ${identical}/${pairs.length} pairs raster identically`, ''); + for (const entry of [...bad, ...raster]) lines.push(`- ${entry}`); + lines.push(''); +} +writeFileSync(path.join(outRoot, 'seam-report.md'), `${lines.join('\n')}\n`); +console.log(lines.join('\n')); +process.exit(failures === 0 ? 0 : 1); diff --git a/tools/paint-parity/seams.mjs b/tools/paint-parity/seams.mjs new file mode 100644 index 000000000..085913d3f --- /dev/null +++ b/tools/paint-parity/seams.mjs @@ -0,0 +1,44 @@ +// Scroll-seam instrument driver: neighbouring scrolled windows, lowered by +// the engine one by one and as one run, rastered by every pen and stacked +// against each other. +// +// node tools/paint-parity/seams.mjs [outRoot] +// +// Lowering: cargo test rito-core lower_scroll_seam_fixtures → /lowered/seam-* +// Flutter pen: flutter test packages/rito_flutter/test/paint_parity_render_test.dart → flutter/ +// Browser pen: tools/paint-parity/render-browser.mjs → browser*/ +// Verdict: /seam-report.md via seam-diff.mjs +import { spawnSync } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import path from 'node:path'; + +const REPO = new URL('../..', import.meta.url).pathname; +const HERE = new URL('.', import.meta.url).pathname; +const outRoot = path.resolve(process.argv[2] ?? path.join(HERE, 'out-seams')); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: 'inherit', ...options }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited ${result.status}`); + } +} + +rmSync(outRoot, { recursive: true, force: true }); +run( + 'cargo', + ['test', '-p', 'rito-core', '--lib', 'lower_scroll_seam_fixtures', '--', '--nocapture'], + { cwd: REPO, env: { ...process.env, RITO_SCROLL_SEAMS_OUT: outRoot } }, +); +// The tester talks to its own loopback server, which a proxy must not take. +const loopback = '127.0.0.1,localhost,::1'; +run('flutter', ['test', 'test/paint_parity_render_test.dart', '--reporter', 'compact'], { + cwd: path.join(REPO, 'packages/rito_flutter'), + env: { + ...process.env, + NO_PROXY: loopback, + no_proxy: loopback, + RITO_PAINT_PARITY_OUT: outRoot, + }, +}); +run(process.execPath, [path.join(HERE, 'render-browser.mjs'), outRoot]); +run(process.execPath, [path.join(HERE, 'seam-diff.mjs'), outRoot]); diff --git a/tools/reader-accept/accept.mjs b/tools/reader-accept/accept.mjs index 31fbd9989..c8bd6aad1 100644 --- a/tools/reader-accept/accept.mjs +++ b/tools/reader-accept/accept.mjs @@ -2,9 +2,7 @@ // latency in the real reader, judged only by public controller calls and // canvas pixels, and the same font-size change made through the settings // panel. -import { createRequire } from 'node:module'; -const require = createRequire(new URL('../../apps/reader/package.json', import.meta.url)); -const { chromium } = require('@playwright/test'); +import { launchOracleChromium } from '../oracle-chromium.mjs'; const READER_URL = process.env.URL ?? 'http://127.0.0.1:4317/'; const BOOK = process.env.BOOK; @@ -13,7 +11,7 @@ const viewport = VIEW === 'phone' ? { width: 393, height: 760, dsf: 3 } : { width: 1280, height: 860, dsf: 2 }; const RUNS = Number(process.env.RUNS ?? 3); -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const out = []; for (let run = 0; run < RUNS; run++) { const page = await browser.newPage({ diff --git a/tools/reader-accept/app-scroll-accept.mjs b/tools/reader-accept/app-scroll-accept.mjs new file mode 100644 index 000000000..4f6de35a1 --- /dev/null +++ b/tools/reader-accept/app-scroll-accept.mjs @@ -0,0 +1,80 @@ +// Acceptance probe for scrolled reading in the reader app: opens the demo +// paged, turns the layout setting to Scrolled through the context menu, +// scrolls, follows a contents entry, and turns back to double pages, writing +// a screenshot of each step to OUT and printing where each mode reads and +// what the page logged as errors or warnings. +// +// Usage: OUT= node tools/reader-accept/app-scroll-accept.mjs +// env URL (default http://localhost:5173/) +import { launchOracleChromium } from '../oracle-chromium.mjs'; +const OUT = process.env.OUT; +const browser = await launchOracleChromium(); +const page = await browser.newPage({ + viewport: { width: 1280, height: 860 }, + deviceScaleFactor: 2, +}); +const errors = []; +page.on('pageerror', (e) => errors.push(String(e))); +page.on('console', (m) => { + if (m.type() === 'error' || m.type() === 'warning') errors.push(m.text()); +}); +await page.goto(process.env.URL ?? 'http://localhost:5173/'); +await page.waitForTimeout(800); +await page.getByRole('button', { name: /demo/i }).first().click(); +await page.waitForSelector('[data-testid="reader-shell"][data-loaded="true"]', { timeout: 60000 }); +await page.waitForTimeout(1500); +const pagedBefore = await page.evaluate( + () => window.__ritoController?.position?.sourceLocator ?? null, +); +await page.screenshot({ path: `${OUT}/1-paged.png` }); +// Settings → Scrolled +await page.keyboard.press('Escape'); +await page.mouse.click(640, 430, { button: 'right' }); +await page.waitForTimeout(400); +await page.getByText('Reader Settings', { exact: true }).click(); +await page.waitForTimeout(500); +await page.screenshot({ path: `${OUT}/2-settings.png` }); +await page.getByText('Scrolled', { exact: true }).click(); +await page.waitForSelector('[data-spread-mode="scrolled"][data-loaded="true"]', { timeout: 60000 }); +await page.keyboard.press('Escape'); +await page.waitForTimeout(1500); +await page.screenshot({ path: `${OUT}/3-scrolled.png` }); +const progress1 = await page.getAttribute('[data-spread-mode="scrolled"]', 'data-scroll-progress'); +await page.mouse.move(640, 430); +await page.mouse.wheel(0, 2400); +await page.waitForTimeout(1200); +const progress2 = await page.getAttribute('[data-spread-mode="scrolled"]', 'data-scroll-progress'); +await page.screenshot({ path: `${OUT}/4-scrolled-down.png` }); +// TOC +await page.getByRole('button', { name: 'Table of contents' }).click(); +await page.waitForTimeout(500); +await page.screenshot({ path: `${OUT}/5-toc.png` }); +const entries = page.locator('[role="dialog"] button, [role="dialog"] a'); +const count = await entries.count(); +if (count > 2) await entries.nth(2).click(); +await page.waitForTimeout(1200); +const progress3 = await page.getAttribute('[data-spread-mode="scrolled"]', 'data-scroll-progress'); +await page.screenshot({ path: `${OUT}/6-after-toc.png` }); +await page.keyboard.press('Escape'); +await page.waitForTimeout(400); +// Back to pages +await page.getByRole('button', { name: 'Settings' }).click(); +await page.waitForTimeout(500); +await page.getByText('Double Page', { exact: true }).click(); +await page.keyboard.press('Escape'); +await page.waitForSelector('[data-testid="reader-shell"]:not([data-spread-mode="scrolled"])', { + timeout: 30000, +}); +await page.waitForTimeout(1500); +await page.screenshot({ path: `${OUT}/7-paged-again.png` }); +const pagedAfter = await page.evaluate( + () => window.__ritoController?.position?.sourceLocator ?? null, +); +console.log( + JSON.stringify( + { pagedBefore, progress1, progress2, tocEntries: count, progress3, pagedAfter, errors }, + null, + 1, + ), +); +await browser.close(); diff --git a/tools/reader-accept/scroll-accept.mjs b/tools/reader-accept/scroll-accept.mjs new file mode 100644 index 000000000..a46a97af4 --- /dev/null +++ b/tools/reader-accept/scroll-accept.mjs @@ -0,0 +1,159 @@ +// Acceptance probe for scrolled reading on the standalone scroll page +// (apps/reader/scroll.html): scrolls across three chapter boundaries +// watching frame gaps and long tasks, selects across a window seam with the +// mouse, follows a link into the book, and reads the screen readers' +// mirror — all through the page's public view (`window.__ritoScroll`) and +// what is on screen. +// +// Usage: BOOK= node tools/reader-accept/scroll-accept.mjs +// env URL (default http://127.0.0.1:5173/scroll.html), VIEW (phone|desktop) +import { launchOracleChromium } from '../oracle-chromium.mjs'; + +const URL = process.env.URL ?? 'http://127.0.0.1:5173/scroll.html'; +const BOOK = process.env.BOOK; +if (!BOOK) { + console.error('usage: BOOK= node scroll-accept.mjs'); + process.exit(1); +} +const VIEW = process.env.VIEW ?? 'desktop'; +const viewport = + VIEW === 'phone' ? { width: 393, height: 760, dsf: 3 } : { width: 1280, height: 860, dsf: 2 }; + +const browser = await launchOracleChromium(); +const page = await browser.newPage({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: viewport.dsf, +}); +const errors = []; +page.on('pageerror', (error) => errors.push(String(error))); +page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); +}); +await page.goto(URL); +await page.setInputFiles('#open', BOOK); +await page.waitForSelector('#view[data-loaded="true"]', { timeout: 60000 }); +await page.waitForTimeout(1500); + +const result = { book: BOOK, view: VIEW }; + +// Chapter boundaries along the axis, in CSS pixels from the book's start. +result.boundaries = await page.evaluate(() => { + const layout = window.__ritoScroll.reader.getScrollLayout(); + let at = 0; + return layout.chapters.map((chapter) => (at += chapter.extent)).slice(0, -1); +}); + +// Glide across the first three boundaries a frame at a time, as a wheel +// would, recording each frame's gap and every long task. +result.scrolling = await page.evaluate(async (boundaries) => { + const { controller } = window.__ritoScroll; + const long = []; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) long.push(Math.round(entry.duration)); + }); + observer.observe({ type: 'longtask', buffered: false }); + const gaps = []; + const crossed = []; + for (const boundary of boundaries.slice(0, 3)) { + controller.scrollTo(Math.max(0, boundary - 600)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + let last = performance.now(); + while (controller.offset < boundary + 600) { + controller.scrollBy(24); + await new Promise((resolve) => requestAnimationFrame(resolve)); + const now = performance.now(); + gaps.push(now - last); + last = now; + if (controller.offset >= controller.extent - 1) break; + } + crossed.push(controller.currentSpread); + } + observer.disconnect(); + gaps.sort((a, b) => a - b); + return { + crossed, + frames: gaps.length, + p50: Math.round(gaps[Math.floor(gaps.length / 2)] ?? 0), + p99: Math.round(gaps[Math.floor(gaps.length * 0.99)] ?? 0), + max: Math.round(gaps.at(-1) ?? 0), + longTasks: long, + }; +}, result.boundaries); + +// A mouse drag from above a window seam to below it: the seam between the +// first two windows of the book's longest chapter, text on either side, +// at the view's middle. +result.selection = await page.evaluate(async () => { + const { controller, reader } = window.__ritoScroll; + const layout = reader.getScrollLayout(); + let start = 0; + let longest = { start: 0, extent: 0 }; + for (const chapter of layout.chapters) { + if (chapter.extent > longest.extent) longest = { start, extent: chapter.extent }; + start += chapter.extent; + } + const seam = longest.start + layout.windowLength; + controller.scrollTo(seam - window.innerHeight / 2); + await new Promise((resolve) => setTimeout(resolve, 600)); + return { seamAt: seam - controller.offset }; +}); +const mid = { x: viewport.width / 2, y: result.selection.seamAt }; +await page.mouse.move(mid.x - 120, mid.y - 60); +await page.mouse.down(); +await page.mouse.move(mid.x, mid.y, { steps: 6 }); +await page.mouse.move(mid.x + 120, mid.y + 60, { steps: 6 }); +await page.mouse.up(); +await page.waitForTimeout(600); +Object.assign( + result.selection, + await page.evaluate(() => { + const { controller } = window.__ritoScroll; + const rects = controller.selectionRects(); + return { + text: controller.selection?.selectedText.slice(0, 60) ?? null, + rects: rects.length, + above: rects.some((rect) => rect.y < window.innerHeight / 2), + below: rects.some((rect) => rect.y + rect.height > window.innerHeight / 2), + }; + }), +); + +// The screen readers' mirror holds the text on screen once it rests. +result.mirror = await page.evaluate( + () => document.querySelector('[aria-live]')?.textContent?.trim().length ?? 0, +); + +// The book's first link into itself, shown at the reading line and clicked +// at its centre, moves the strip to its target. +result.link = await page.evaluate(async () => { + const { controller, reader } = window.__ritoScroll; + for (let spread = 0; spread < reader.spreads.length; spread += 1) { + const pageIndex = reader.spreads[spread]?.leftPageIndex; + if (pageIndex === undefined) continue; + const targets = await reader.interactions.getPageTargets(pageIndex); + const link = targets?.targets.find((target) => target.targetLocator); + if (!link) continue; + controller.scrollToSpread(spread, Math.max(0, link.bounds.y - 100)); + await new Promise((resolve) => setTimeout(resolve, 600)); + const [rect] = controller.viewRects([{ pageIndex, ...link.bounds }]); + if (!rect) return { spread, shown: false }; + const box = controller.canvas.getBoundingClientRect(); + return { + spread, + x: box.left + rect.x + rect.width / 2, + y: box.top + rect.y + rect.height / 2, + href: link.targetLocator.href, + }; + } + return null; +}); +if (result.link?.x !== undefined) { + const before = await page.evaluate(() => window.__ritoScroll.controller.offset); + await page.mouse.click(result.link.x, result.link.y); + await page.waitForTimeout(1200); + result.link.moved = (await page.evaluate(() => window.__ritoScroll.controller.offset)) !== before; +} + +result.errors = errors; +console.log(JSON.stringify(result, null, 2)); +await browser.close(); diff --git a/tools/reader-accept/settings-storm.mjs b/tools/reader-accept/settings-storm.mjs index 3d2fdc3f4..e86a3a1a5 100644 --- a/tools/reader-accept/settings-storm.mjs +++ b/tools/reader-accept/settings-storm.mjs @@ -8,9 +8,7 @@ // BOOK=/path/to.epub URL=http://127.0.0.1:5173/ node settings-storm.mjs // // Exits 1 when it does not pass. DUMP=file also writes the reader's debug log. -import { createRequire } from 'node:module'; -const require = createRequire(new URL('../../apps/reader/package.json', import.meta.url)); -const { chromium } = require('@playwright/test'); +import { launchOracleChromium } from '../oracle-chromium.mjs'; const READER_URL = process.env.URL ?? 'http://127.0.0.1:4317/'; const BOOK = process.env.BOOK; @@ -21,7 +19,7 @@ const viewport = const GAPS = (process.env.GAPS ?? '0,4,16,40,90,160,260,400').split(',').map(Number); const BURST = Number(process.env.BURST ?? 6); -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.dsf, diff --git a/tools/reader-accept/turn-trace.mjs b/tools/reader-accept/turn-trace.mjs index 2c63af8cf..7715aa52a 100644 --- a/tools/reader-accept/turn-trace.mjs +++ b/tools/reader-accept/turn-trace.mjs @@ -12,9 +12,7 @@ // of it on every frame); SAMPLE=light reads one row; SAMPLE=none reads // nothing and prints the frames of the first quarter second, to tell the // cost of watching from the cost of turning. -import { createRequire } from 'node:module'; -const require = createRequire(new URL('../../apps/reader/package.json', import.meta.url)); -const { chromium } = require('@playwright/test'); +import { launchOracleChromium } from '../oracle-chromium.mjs'; const READER_URL = process.env.URL ?? 'http://127.0.0.1:4317/'; const BOOK = process.env.BOOK; @@ -29,7 +27,7 @@ const ACTION = process.env.ACTION ?? 'turn'; // one paying for something else. const RASTER_FIRST = process.env.RASTER_FIRST === '1'; -const browser = await chromium.launch(); +const browser = await launchOracleChromium(); const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.dsf, diff --git a/tools/resize-frames/shared.mjs b/tools/resize-frames/shared.mjs index 7f3367e11..a00ab419f 100644 --- a/tools/resize-frames/shared.mjs +++ b/tools/resize-frames/shared.mjs @@ -1,9 +1,6 @@ // What both resize measurements share: a Chromium with no frame-rate limit // on the machine's own GPU, and order statistics. -import { createRequire } from 'node:module'; - -const require = createRequire(new URL('../../apps/reader/package.json', import.meta.url)); -const { chromium } = require('@playwright/test'); +import { launchOracleChromium } from '../oracle-chromium.mjs'; /** * Chromium paces frames at 60 Hz unless the GPU's vsync and the frame-rate @@ -12,15 +9,13 @@ const { chromium } = require('@playwright/test'); * screen is still paced at 60 Hz: each measured frame must damage a pixel. */ export function launchUnthrottled() { - return chromium.launch({ - args: [ - '--disable-gpu-vsync', - '--disable-frame-rate-limit', - '--use-angle=metal', - '--enable-gpu', - '--ignore-gpu-blocklist', - ], - }); + return launchOracleChromium([ + '--disable-gpu-vsync', + '--disable-frame-rate-limit', + '--use-angle=metal', + '--enable-gpu', + '--ignore-gpu-blocklist', + ]); } export function stats(values) {