stationery 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +65 -19
- data/lib/stationery/css/stylesheet.rb +9 -1
- data/lib/stationery/html/css.rb +31 -6
- data/lib/stationery/html/tree_builder/blocks.rb +7 -3
- data/lib/stationery/layout/box/wrapping.rb +52 -0
- data/lib/stationery/layout/box.rb +38 -12
- data/lib/stationery/layout/columns/leveller.rb +65 -0
- data/lib/stationery/layout/columns/pour.rb +7 -0
- data/lib/stationery/layout/columns.rb +32 -15
- data/lib/stationery/layout/flow/placement.rb +4 -3
- data/lib/stationery/layout/list_item.rb +30 -9
- data/lib/stationery/minitest.rb +6 -3
- data/lib/stationery/pdf/conformance.rb +6 -3
- data/lib/stationery/rich/nodes.rb +4 -4
- data/lib/stationery/rich/renderer/boxes.rb +37 -16
- data/lib/stationery/rich/renderer.rb +3 -2
- data/lib/stationery/tagging/element.rb +6 -0
- data/lib/stationery/tagging/tree.rb +53 -2
- data/lib/stationery/testing/field_page.rb +89 -0
- data/lib/stationery/testing/inspector.rb +12 -2
- data/lib/stationery/testing/marked_text.rb +124 -18
- data/lib/stationery/testing/matchers.rb +26 -14
- data/lib/stationery/text/exclusions.rb +11 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +10 -1
- data/lib/stationery.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b208319414f7dea1a6af8a89b47aec788e1135ace442031a1d05623240483d7c
|
|
4
|
+
data.tar.gz: f9d15f729afcfd2fa6861b01b365462b4a5d413a4ce9b6fb101c2b7c3b9f68db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fca53eecf78b67b65c7bb9b9582d625f6e693ba9ff29264423f9c1028d3ea8b2477d69af7858edde8169b91b592d43a364d4874703cf719173158c09f187f4b1
|
|
7
|
+
data.tar.gz: 9bd72b967a7e1cd32acf650e5662d009cf792e23f1fef9cec3df579b74fad916f2ed4a0d6d96bdd0b0bbefcecc85f7d277510ff69b486c742d0d00ae169ac8da
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.1 (2026-09-28)
|
|
4
|
+
|
|
5
|
+
What putting 0.11.0 to work found: PDF/UA claims that hold, boxes that stay on their page, columns that fill evenly, and lists that widen below a float.
|
|
6
|
+
|
|
7
|
+
- PDF/UA-1 refuses heading levels that are skipped (ISO 14289-1, 7.4.2; veraPDF rule 7.4.2-1): the first heading of a document is `heading: 1` and a heading is at most one level below the heading before it, so `heading: 1` followed by `heading: 3`, or a document that starts at `heading: 2`, raises `Stationery::ConformanceError` with an issue per heading (`heading 3 on page 2 skips a level: heading 2 is the deepest that may follow heading 1`) instead of writing a file that claims a level it does not keep. Going back up is free (`3`, then `1`), and a heading that skipped is what the next one is measured against. Headings are read in the order of the structure tree, which is what a validator follows, inside sections, lists, table cells, columns and floats, from `text(heading:)`, `html` and `markdown` alike; the headings of headers, footers and page templates are artifacts and a heading without text is not written, so neither counts. In a tagged render without the claim it is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`), so `strict` catches it.
|
|
8
|
+
- A blank `alt:` is a missing one: `image(source, alt: "")`, `svg(source, alt: " ")` and a `markdown` image without a description (``) are reported as `Warnings::MissingAlt` in a tagged render and raise `ConformanceError` under PDF/UA-1 (ISO 14289-1, 7.3; veraPDF rule 7.3-1 fails an empty `/Alt`), where only a missing `alt:` was caught. An `alt:` of whitespace alone is refused as well, although veraPDF accepts it: it describes as little as the empty one. `alt: false` still marks decoration, which is an artifact and needs no description, and the message of `MissingAlt` now names it: `image on page 1 has no alt: text (alt: false marks decoration)`.
|
|
9
|
+
- In `html`, `<img alt="">` is decoration, as HTML means it: an `alt` attribute that is there and empty (or whitespace alone) draws the image as `alt: false` does, an artifact outside the structure tree, floated or not, so it neither warns nor raises. An `<img>` without an `alt` attribute is still a missing description. Markdown has no way to say that an image is decorative, so `` counts as a description forgotten: use `html` or the `image` element for decoration.
|
|
10
|
+
- A `box` that splits across pages no longer runs over the page by the padding and border that close it. A head is open at the bottom, so its content was cut at the height less the top of the box alone: content that ended on the page by that measure was placed with the bottom padding under it, up to that much over the edge, and reported as an `Overflow`. The last of the content now goes to the next page with what closes the box; content that cannot be cut there moves whole, and first on a fresh page it is kept and reported as before. Boxes inside boxes added their paddings up: 29 of 200 random flows of nested padded boxes overflowed, none do. A box that did not overflow is laid out as it was.
|
|
11
|
+
- Balanced `columns` fill evenly: ten lines in three columns are 4, 3 and 3 (they were 4, 4 and 2, the last column taking what was left over), thirteen in four are 4, 3, 3 and 3. At the balanced height the first column takes what fits, and what it leaves is balanced through the columns after it in the same way, down to the last two, so what cannot be shared goes to the earlier columns. Every column is still cut where a page would cut it, so `orphans:`/`widows:`, `keep_with_next`, `break_inside: :avoid` and blocks that cannot split hold as they did; the columns filled in order are kept where the levelled ones would not place everything, would change the height of the block, or would have a later column taller than an earlier one where there was none. The block is exactly as tall as it was, so what follows it stays where it is, and a block of two columns, `balance: false`, the full pages of a block that continues and documents without `columns` are byte for byte what they were. A block is levelled once, when it is placed, with at most `(count - 2) * 41` pours more than its balancing: 700 paragraphs in two and three columns render in the time they did. `Layout::Columns::Leveller`, `Layout::Columns::Pour#first`.
|
|
12
|
+
- Beside a float, a list item and a `box` that paints nothing of its own wrap what they hold: they keep the full width of their flow, and their lines are narrow beside the float and take the full width below it, in the middle of a paragraph if need be. They were blocks of the width the float left, all the way down, so a long item or box beside a short image stayed narrow below it. That is a box without `background:`, `border:`, `shadow:` or `link:` and without `width:`, `height:`, `rotate:`, `overflow:` or `valign:`; its padding lies under the float, as a block's does in CSS. A list item keeps its indent from the float, with the marker beside its first line. They split across pages as they did (`break_inside:`, `keep_with_next:`, `orphans:` and `widows:` inside them), the lines carried over are wrapped again at the full width, and a tagged PDF reads them in the order they were written. In `html`, the items of a list beside a floated `img` wrap, and so does a block indented by `margin-left` or `margin-right`, whose margin lies under the float. A box with a background, a border, a shadow or a link stays a block of the width that is left: it is painted after the float written before it, and at the full width it would paint over the float. So do a `table`, a `row` and `columns`, whose columns depend on their width. Documents without floats are byte for byte what they were, and allocate what they did. `Layout::Box::Wrapping`, `Text::Exclusions#inset`.
|
|
13
|
+
- `html` reads `margin-left` and `margin-right`, which were reported as `Warnings::UnsupportedCss`: lengths in `px` or `pt`, as `margin-top` takes them. On a floated `img` they are that side of the space kept around it, so `style="float: right; margin-left: 12pt"` keeps 12 pt between the image and the text. On a block (`p`, `div`-like containers, headings, lists, tables, `blockquote`, `pre`) they indent it from that side, its background and padding inside the margin; the block breaks across pages where it did, and a heading or a paragraph keeps its `keep_with_next`. `margin` and its sides are read in the order of the cascade, specificity first and position then: a side wins over a `margin` declared before it, and a `margin` takes back the sides declared before it. `auto`, `em` and `%` are reported as they were, and so is a negative `margin-left` or `margin-right`. HTML without margins renders byte for byte as before. `CSS::Stylesheet#matching`.
|
|
14
|
+
- Testing: `Inspector#text` and `#page_texts`, and so `have_pdf_text`, `have_pdf_text_on_page` and `assert_pdf_text`, read a sequence with `/ActualText` as that text, once, whatever the glyphs inside it advance. A shaped stretch whose first glyph has no advance (a mark drawn before its base, as HarfBuzz answers for Arabic) was missing from the text: pdf-reader gives the `ActualText` to the first glyph shown and drops a glyph without a width. The helpers now read a page through the receiver `Inspector#structure` already read it with: glyphs outside such a sequence are laid out by pdf-reader as before, and the sequence is one run from where its first glyph is shown to where it ends. The text of a page without `ActualText` is what it was. `ActualText` and the structure tree are read with pdf-reader 2.12 and later, where they needed 2.16. For a PDF Stationery did not write, `untagged_text` and `structure` follow a form XObject the page draws, as `text` does. The PDFs written are unchanged.
|
|
15
|
+
- Testing: `fields: true` reads what form fields show. A field's value is drawn by its widget's appearance, a form XObject of the annotation and no part of the page content, so `have_pdf_text("Astrid")` does not find the value of `text_field "name", value: "Astrid"`. `have_pdf_text("Astrid", fields: true)`, `have_pdf_text_on_page(1, "Astrid", fields: true)`, `assert_pdf_text pdf, "Astrid", fields: true`, `refute_pdf_text`, `Inspector#text(fields: true)` and `#page_texts(fields: true)` read the normal appearance of every widget with the page content, each where the widget is on its page: text fields (every line of `multiline:`, every cell of `comb:`), a select's value, a signature field's label, characters no font has from their `ActualText`, a field set in the standard Helvetica, in an encrypted document as in a plain one. A widget that is hidden (`Hidden`, `NoView`) is not read, and of a button's appearances the one its state names, never `Off`. It is an option and not the default, so no text a spec reads today changes: the text of a form with its fields is other text (more words, and other blank lines between those of the page content), which `not_to have_pdf_text` and a comparison of `Inspector#text` would notice. Without the option, the failure of `have_pdf_text` over a text that a field shows says so.
|
|
16
|
+
- **Behaviour changes:** in `html`, the left and right of the `margin` shorthand indent a block, as CSS means them: `margin: 0 20px` drew no indent. Top and bottom draw what they drew. A `margin` declared after `margin-top` or `margin-bottom`, or by a more specific rule, takes them back, where the side always won. A negative margin on a floated `img` is drawn as none: it moved the image out of the flow, off the page when it was large, which nothing in a style may do. Beside a float, a list or a box without a background or a border is laid out at the full width with its lines around the float, where it was laid out at the width left beside it; a document with floats and such content may break its pages elsewhere. A render with `conformance :pdf_ua1` whose heading levels skip, or with a figure whose `alt:` is blank, now raises `ConformanceError` (it wrote a file veraPDF rejected); a tagged render reports both as warnings, so with `strict` it raises `WarningsError`. PDF/A alone asks for neither (level B does not ask for structure) and raises for neither. In a tagged render an `html` image with `alt=""` is an artifact, where it was a `Figure` with an empty `/Alt`. The message of `MissingAlt`, and with it the issue of the `ConformanceError`, ends in `(alt: false marks decoration)`. A `box` that ran over the page by the padding or border that closes it breaks a line or more earlier, and no longer reports an `Overflow`. Documents that are not tagged, and tagged documents that keep both rules and have no `<img alt="">`, are byte for byte what they were.
|
|
17
|
+
|
|
3
18
|
## 0.11.0 (2026-09-28)
|
|
4
19
|
|
|
5
20
|
Text wrap around images, balanced columns, lossless WebP, a shaper hook for complex scripts, and far less memory for long documents.
|
data/README.md
CHANGED
|
@@ -83,7 +83,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
|
|
|
83
83
|
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
|
|
84
84
|
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, float:, margin:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. `float: :left` or `:right` with a `width:` takes it to that side, `margin:` away from the text that wraps beside it: see [Floats](#floats). |
|
|
85
85
|
| `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
|
|
86
|
-
| `columns(count:, gap:, balance:, rule:) { }` | One flow poured through `count` columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with `orphans:`/`widows:`, never inside `break_inside: :avoid`, `keep_with_next` honoured). `balance: true` (default) ends the columns at nearly the same height where the content ends; `false` fills each before the next. Continues across pages; `page_break` inside ends the page; `rule: true \| { color:, width: }` draws a line between columns. |
|
|
86
|
+
| `columns(count:, gap:, balance:, rule:) { }` | One flow poured through `count` columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with `orphans:`/`widows:`, never inside `break_inside: :avoid`, `keep_with_next` honoured). `balance: true` (default) ends the columns at nearly the same height where the content ends and fills them evenly, what cannot be shared going to the earlier columns (ten lines in three columns are 4, 3 and 3); `false` fills each before the next. Continues across pages; `page_break` inside ends the page; `rule: true \| { color:, width: }` draws a line between columns. |
|
|
87
87
|
| `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
|
|
88
88
|
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:, float:, margin:)` | JPEG, PNG or lossless WebP, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG or WebP to that resolution instead. |
|
|
89
89
|
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors); `use`, `symbol` sprites and nested `svg` viewports (`viewBox`, `preserveAspectRatio`); `clipPath` (both `clipPathUnits`). |
|
|
@@ -136,7 +136,14 @@ text more # around the pull quote
|
|
|
136
136
|
- Text (paragraphs, headings, the text inside a `group` or an `html` block) wraps: every line takes
|
|
137
137
|
the width left at its own top and is aligned and justified in it. A paragraph whose widest word
|
|
138
138
|
does not fit beside the floats starts below them.
|
|
139
|
-
-
|
|
139
|
+
- A list item and a `box` that paints nothing of its own wrap what they hold: they keep the full
|
|
140
|
+
width, and their lines are narrow beside the float and wide below it. That is a box without
|
|
141
|
+
`background:`, `border:`, `shadow:` or `link:` and without `width:`, `height:`, `rotate:`,
|
|
142
|
+
`overflow:` or `valign:`. Its padding lies under the float, as a block's does in CSS, so beside
|
|
143
|
+
the float its lines are the float's `margin:` away from it. A list item keeps its indent from the
|
|
144
|
+
float, with the marker beside its first line.
|
|
145
|
+
- Anything else (a `box` with a background, a border or a size of its own, a `row`, `columns`, `table`,
|
|
146
|
+
`rule`, `image`, `svg`, form field) is a block:
|
|
140
147
|
it goes beside the float in the width that is left, and keeps that width all the way down, when
|
|
141
148
|
its own width (or the least its content takes) fits there; else it starts below the float. A
|
|
142
149
|
`spacer` takes its height beside the float; a `page_break` ends the page and the float with it.
|
|
@@ -147,7 +154,8 @@ text more # around the pull quote
|
|
|
147
154
|
- Across pages a float never splits. When it does not fit what is left of the page, or the content
|
|
148
155
|
after it could not start beside it there, it moves to the next page with that content. A
|
|
149
156
|
paragraph beside a float splits between lines as always (`orphans:`, `widows:`); the lines carried
|
|
150
|
-
over are wrapped again at the full width, because the float stayed behind.
|
|
157
|
+
over are wrapped again at the full width, because the float stayed behind. So are those of a list
|
|
158
|
+
item and of a box.
|
|
151
159
|
- In a tagged PDF the float is where it was written: an image is a `Figure`, a box has its `role:`.
|
|
152
160
|
|
|
153
161
|
A line beside a float is taken to be as tall as a line of the paragraph's own style when its width
|
|
@@ -212,17 +220,19 @@ ignored and reported. At-rules are skipped, except that rules inside `@media pri
|
|
|
212
220
|
| `text-align` | `left`, `center`, `right`, `justify` | paragraphs, headings, cells, images; inherited from a container |
|
|
213
221
|
| `background-color` | as `color`, or `transparent` | `p`, `div` and other containers, `blockquote`, `pre`, `table`, `td`, `th` |
|
|
214
222
|
| `padding`, `padding-top` … `padding-left` | one to four lengths in `px` or `pt` | the same |
|
|
215
|
-
| `margin`, `margin-top
|
|
223
|
+
| `margin`, `margin-top` … `margin-left` | one to four lengths in `px` or `pt`: top and bottom are added to `gap:`, left and right indent the block. A negative margin is drawn as none | blocks, and a floated `img` |
|
|
216
224
|
| `border` | `1px solid #ccc` in any order, `none` | `table` (every cell), `td`, `th` |
|
|
217
225
|
| `width` | `px`, `pt`, `%`, `auto` | `img`, `table`, and `td`/`th` (column widths, when every cell of the first row has one) |
|
|
218
|
-
| `float` | `left`, `right`, `none` | `img` only: the text that follows wraps beside it. Its `margin`
|
|
226
|
+
| `float` | `left`, `right`, `none` | `img` only: the text that follows wraps beside it. Its `margin`, with `margin-top` … `margin-left` over it, is kept around it, else `styles: { img: { float_margin: 8 } }` towards the text |
|
|
219
227
|
| `page-break-before`, `page-break-after`, `break-before`, `break-after` | `always`, `page`, `auto` | blocks |
|
|
220
228
|
| `page-break-inside`, `break-inside` | `avoid`, `auto` | blocks |
|
|
221
229
|
| `column-count`, `columns` | a number of columns, `auto` (a column width is not read) | `div` and other containers, `p`, headings, lists, tables, `blockquote`, `pre` |
|
|
222
230
|
| `column-gap` | a length in `px` or `pt`, `normal` | the same |
|
|
223
231
|
|
|
232
|
+
`margin` and its sides are read in the order of the cascade: a side wins over a `margin` declared
|
|
233
|
+
before it or by a lesser rule, and a `margin` takes back the sides declared before it.
|
|
224
234
|
`<font color size>`, `<center>` and `<img align="left|right">` (a float) are read the same way.
|
|
225
|
-
Everything else (`display`, `float` on anything but an image, `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `url()` values,
|
|
235
|
+
Everything else (`display`, `float` on anything but an image, `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `auto` margins, a negative `margin-left` or `margin-right`, `url()` values,
|
|
226
236
|
inline backgrounds) is ignored and named in the `UnsupportedCss` warning, so `strict` catches
|
|
227
237
|
content that expects more than this. No value is ever fetched: `url()` and `@import` are dropped.
|
|
228
238
|
|
|
@@ -347,6 +357,10 @@ signature_field "signature", label: "Signature of the applicant"
|
|
|
347
357
|
signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
|
|
348
358
|
- `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
|
|
349
359
|
`false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
|
|
360
|
+
- In tests, `document.fields` is what the form was filled with, and `have_pdf_text("Astrid", fields:
|
|
361
|
+
true)` or `assert_pdf_text pdf, "Astrid", fields: true` finds what a field shows on the page. A
|
|
362
|
+
value is drawn by its widget and is no part of the page content, so the text leaves it out
|
|
363
|
+
without `fields: true` (see [Testing](#testing)).
|
|
350
364
|
|
|
351
365
|
## Components
|
|
352
366
|
|
|
@@ -481,7 +495,9 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
|
|
|
481
495
|
- `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
|
|
482
496
|
page number).
|
|
483
497
|
- `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes
|
|
484
|
-
`BlockQuote`, and give images their `alt`.
|
|
498
|
+
`BlockQuote`, and give images their `alt`. In `html`, `<img alt="">` is decoration as `alt: false`
|
|
499
|
+
is (an `<img>` without the attribute is a description missing). Markdown has no way to say so:
|
|
500
|
+
`` is a description missing, and decoration goes through `html` or `image`.
|
|
485
501
|
- Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
|
|
486
502
|
outside any element are layout artifacts.
|
|
487
503
|
- `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
|
|
@@ -489,8 +505,15 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
|
|
|
489
505
|
`dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
|
|
490
506
|
`pdf:Producer`. PDF/A and PDF/UA identification lives there (see
|
|
491
507
|
[PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
|
|
492
|
-
- An image or drawing without `alt
|
|
493
|
-
|
|
508
|
+
- An image or drawing without `alt:`, or with a blank one (`alt: ""`, whitespace alone, which
|
|
509
|
+
veraPDF would accept), is a `Warnings::MissingAlt`; `alt: false` is how decoration is marked.
|
|
510
|
+
- A heading level that is skipped is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`): the
|
|
511
|
+
first heading is `heading: 1` and a heading is at most one level below the heading before it
|
|
512
|
+
(`1`, then `3` skips `2`). Going back up is free (`3`, then `1`). Headings are read in the order of
|
|
513
|
+
the structure tree, inside sections, lists, table cells, columns and floats; those of headers,
|
|
514
|
+
footers and page templates are artifacts and do not count.
|
|
515
|
+
- A missing `lang` is a `Warnings::MissingLanguage`. All three are warnings, so `strict` catches them,
|
|
516
|
+
and `conformance :pdf_ua1` raises on the first two.
|
|
494
517
|
- Untagged documents (the default) are written exactly as before.
|
|
495
518
|
|
|
496
519
|
Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
|
|
@@ -536,7 +559,10 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
|
|
|
536
559
|
- **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
|
|
537
560
|
`pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
|
|
538
561
|
every link annotation a description (`/Contents`: the URL, or the target page). A figure without
|
|
539
|
-
`alt:` raises; mark decoration with `alt: false
|
|
562
|
+
`alt:` or with a blank one raises (7.3); mark decoration with `alt: false`, or `<img alt="">` in
|
|
563
|
+
`html`. A heading level that is skipped raises (7.4.2): the first heading is `heading: 1`, and a
|
|
564
|
+
heading is at most one level below the heading before it. PDF/A alone asks for neither.
|
|
565
|
+
Encryption is allowed.
|
|
540
566
|
- Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
|
|
541
567
|
- Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
|
|
542
568
|
field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
|
|
@@ -1038,8 +1064,8 @@ The limits of a hook:
|
|
|
1038
1064
|
- Vertical advances are not read: text runs horizontally.
|
|
1039
1065
|
- A reader that takes `ActualText` for the text (the gem's `Inspector`) gets it as written. poppler
|
|
1040
1066
|
(`pdftotext` 26.09) and MuPDF (1.28) run their own reordering over it and return a right-to-left
|
|
1041
|
-
stretch reversed. pdf-reader
|
|
1042
|
-
|
|
1067
|
+
stretch reversed. pdf-reader's own `Page#text` drops a `Span` whose first glyph has no advance (a
|
|
1068
|
+
mark drawn first); the `Inspector` does not.
|
|
1043
1069
|
|
|
1044
1070
|
[`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb)
|
|
1045
1071
|
is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem
|
|
@@ -1073,6 +1099,7 @@ RSpec.describe InvoicePdf do
|
|
|
1073
1099
|
|
|
1074
1100
|
it { is_expected.to have_pdf_text("Invoice INV-7") }
|
|
1075
1101
|
it { is_expected.to have_pdf_text_on_page(2, /Total €[\d ,]+/) }
|
|
1102
|
+
it { is_expected.to have_pdf_text("Astrid Lindqvist", fields: true) } # with what the form fields show
|
|
1076
1103
|
it { is_expected.to have_page_count(2) }
|
|
1077
1104
|
it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
|
|
1078
1105
|
it { is_expected.to have_image_count(1) }
|
|
@@ -1100,6 +1127,7 @@ class InvoicePdfTest < Minitest::Test
|
|
|
1100
1127
|
|
|
1101
1128
|
assert_pdf_text pdf, "Invoice INV-7"
|
|
1102
1129
|
refute_pdf_text pdf, "DRAFT"
|
|
1130
|
+
assert_pdf_text pdf, "Astrid Lindqvist", fields: true
|
|
1103
1131
|
assert_page_count pdf, 2
|
|
1104
1132
|
assert_pdf_link pdf, /acme\.test/
|
|
1105
1133
|
assert_no_pdf_warnings pdf
|
|
@@ -1131,6 +1159,20 @@ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own te
|
|
|
1131
1159
|
between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
|
|
1132
1160
|
`Figure` reads as its alt text.
|
|
1133
1161
|
|
|
1162
|
+
`text` and `page_texts` are the text of the page content as pdf-reader lays it out, line by line. A
|
|
1163
|
+
`Span` with `ActualText` (a stretch a shaper reordered, characters no font has) reads as that text,
|
|
1164
|
+
once, whatever glyphs it shows.
|
|
1165
|
+
|
|
1166
|
+
A form field's value is not page content: its widget draws it, so `have_pdf_text("Astrid")` does not
|
|
1167
|
+
find the value of a `text_field`. `fields: true` reads what the form fields show as well, each where
|
|
1168
|
+
it is on its page: `have_pdf_text("Astrid", fields: true)`, `have_pdf_text_on_page(1, "Astrid",
|
|
1169
|
+
fields: true)`, `assert_pdf_text pdf, "Astrid", fields: true`, `refute_pdf_text`, and
|
|
1170
|
+
`Inspector#text(fields: true)` and `#page_texts(fields: true)`. What is read is the normal appearance
|
|
1171
|
+
of every widget that is not hidden, in the state the widget is in, and never a button's `Off` state
|
|
1172
|
+
(the marks of a `checkbox` and a `radio` are paths and read as nothing). Without `fields: true` the
|
|
1173
|
+
text is what it always was, and the failure of `have_pdf_text` over a text that a field shows says
|
|
1174
|
+
so.
|
|
1175
|
+
|
|
1134
1176
|
## Why not Prawn, Chrome or Typst?
|
|
1135
1177
|
|
|
1136
1178
|
- **Prawn** is an imperative cursor API: every document does its own layout
|
|
@@ -1255,18 +1297,22 @@ Images are JPEG, PNG (non-interlaced) and lossless WebP (not lossy or animated W
|
|
|
1255
1297
|
than 33 megapixels) and never fetched from a URL; a JPEG is embedded at its
|
|
1256
1298
|
source resolution (only PNG and WebP can be downscaled), so an oversized one is reported, not resized. `html` reads a fixed subset of CSS (colours, sizes, weights, alignment, margins, padding, table
|
|
1257
1299
|
borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
|
|
1258
|
-
engine's worth: no `display`, positioning, `font-family` or
|
|
1259
|
-
for images only.
|
|
1300
|
+
engine's worth: no `display`, positioning, `font-family`, `auto` or negative margins or selectors
|
|
1301
|
+
with combinators, and floats for images only, with their `margin` around them.
|
|
1260
1302
|
`markdown` reads no CSS, and raw HTML inside Markdown stays literal text.
|
|
1261
1303
|
|
|
1262
1304
|
Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
|
|
1263
1305
|
splits only when every column can; a rotated box and a `stack` move to the next page whole.
|
|
1264
|
-
`columns` balances to the shortest height that holds the content and fills the columns
|
|
1265
|
-
(ten lines in three columns are 4,
|
|
1266
|
-
|
|
1267
|
-
|
|
1306
|
+
`columns` balances to the shortest height that holds the content and fills the columns evenly
|
|
1307
|
+
from the first one on (ten lines in three columns are 4, 3 and 3): a column takes what fits the
|
|
1308
|
+
height balanced for it and the columns after it, so one that ends above a block that cannot
|
|
1309
|
+
split may stay shorter than the column after it. It has columns of one width, nothing spanning
|
|
1310
|
+
them (end the block, write the full-width content, start another) and no column break of its
|
|
1311
|
+
own; a spacer that lands at the top of a column keeps its height. Text
|
|
1268
1312
|
wraps around floated images and boxes, along their rectangles, never along a shape; beside a float
|
|
1269
|
-
a table,
|
|
1313
|
+
a table, a row, `columns` and a box with a background, a border or a size of its own are blocks of
|
|
1314
|
+
the width that is left, all the way down: a box is painted after the float written before it, so
|
|
1315
|
+
one that kept the full width would paint its background over the float (see
|
|
1270
1316
|
[Floats](#floats)). Link and form-widget rectangles stay in page space inside `rotate`
|
|
1271
1317
|
and `transform`, and `shadow:` is stacked rectangles, not a blur.
|
|
1272
1318
|
|
|
@@ -52,9 +52,17 @@ module Stationery
|
|
|
52
52
|
def empty? = @rules.empty?
|
|
53
53
|
|
|
54
54
|
def declarations(element)
|
|
55
|
+
rules_for(element).reduce({}) { |merged, rule| merged.merge(rule.declarations) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The declarations of every rule that matches, the one that wins last.
|
|
59
|
+
def matching(element) = rules_for(element).map(&:declarations)
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def rules_for(element)
|
|
55
64
|
@rules.select { |rule| rule.selector.match?(element) }
|
|
56
65
|
.sort_by { |rule| [rule.selector.specificity, rule.index] }
|
|
57
|
-
.reduce({}) { |merged, rule| merged.merge(rule.declarations) }
|
|
58
66
|
end
|
|
59
67
|
end
|
|
60
68
|
end
|
data/lib/stationery/html/css.rb
CHANGED
|
@@ -37,9 +37,13 @@ module Stationery
|
|
|
37
37
|
# Properties read on text (inherited by everything inside the element).
|
|
38
38
|
INLINE = %w[color font-size font-weight font-style text-decoration].freeze
|
|
39
39
|
# Properties read on the block an element becomes.
|
|
40
|
-
BLOCK = %w[text-align background-color margin margin-top margin-bottom
|
|
41
|
-
padding-bottom padding-left border width page-break-before
|
|
42
|
-
break-after page-break-inside break-inside column-count column-gap
|
|
40
|
+
BLOCK = %w[text-align background-color margin margin-top margin-right margin-bottom margin-left padding
|
|
41
|
+
padding-top padding-right padding-bottom padding-left border width page-break-before
|
|
42
|
+
page-break-after break-before break-after page-break-inside break-inside column-count column-gap
|
|
43
|
+
columns].freeze
|
|
44
|
+
# The sides `margin` sets, and the two that indent a block: a negative one is not drawn.
|
|
45
|
+
MARGINS = %w[margin-top margin-right margin-bottom margin-left].freeze
|
|
46
|
+
INDENTS = %w[margin-left margin-right].freeze
|
|
43
47
|
FONT_SIZES = { "1" => 0.625, "2" => 0.8125, "3" => 1.0, "4" => 1.125, "5" => 1.5, "6" => 2.0,
|
|
44
48
|
"7" => 3.0 }.freeze
|
|
45
49
|
KEYWORD_SIZES = { "xx-small" => 0.5625, "x-small" => 0.625, "small" => 0.8125, "medium" => 1.0,
|
|
@@ -79,9 +83,17 @@ module Stationery
|
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
# The declarations that apply to `element`: legacy attributes, then the
|
|
82
|
-
# stylesheet by specificity, then its inline style.
|
|
86
|
+
# stylesheet by specificity, then its inline style. A margin declared
|
|
87
|
+
# again stands where it was declared last, since `margin` and its sides
|
|
88
|
+
# are read in that order.
|
|
83
89
|
def declarations(element)
|
|
84
|
-
legacy(element)
|
|
90
|
+
layers = [legacy(element), *@sheet.matching(element), CSS.declarations(element.attributes["style"])]
|
|
91
|
+
layers.each_with_object({}) do |layer, merged|
|
|
92
|
+
layer.each do |name, value|
|
|
93
|
+
merged.delete(name) if name.start_with?("margin")
|
|
94
|
+
merged[name] = value
|
|
95
|
+
end
|
|
96
|
+
end
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
# [marks, block]: the text marks the element's content inherits and the
|
|
@@ -149,8 +161,9 @@ module Stationery
|
|
|
149
161
|
case name
|
|
150
162
|
when "text-align" then read(name, value, block, :align) { align(value) }
|
|
151
163
|
when "background-color" then read(name, value, block, :background) { color(value) }
|
|
152
|
-
when "margin" then
|
|
164
|
+
when "margin" then margin(value, block)
|
|
153
165
|
when "padding" then read(name, value, block, :padding) { box(value) }
|
|
166
|
+
when *INDENTS then read(name, value, block, name.tr("-", "_").to_sym) { indent(value) }
|
|
154
167
|
when /\A(margin|padding)-/ then read(name, value, block, name.tr("-", "_").to_sym) { points(value) }
|
|
155
168
|
when "border" then read(name, value, block, :border) { border(value) }
|
|
156
169
|
when "width" then read(name, value, block, :width) { width(value) }
|
|
@@ -244,6 +257,18 @@ module Stationery
|
|
|
244
257
|
end
|
|
245
258
|
end
|
|
246
259
|
|
|
260
|
+
# `margin` sets every side, so it takes back the sides declared before
|
|
261
|
+
# it; one with a value that is not read leaves them as they are.
|
|
262
|
+
def margin(value, block)
|
|
263
|
+
sides = read("margin", value, block, :margin) { box(value) }
|
|
264
|
+
MARGINS.each { |name| block.delete(name.tr("-", "_").to_sym) } if sides
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def indent(value)
|
|
268
|
+
length = points(value)
|
|
269
|
+
length unless length&.negative?
|
|
270
|
+
end
|
|
271
|
+
|
|
247
272
|
# One to four lengths as [top, right, bottom, left].
|
|
248
273
|
def box(value)
|
|
249
274
|
sides = value.split.map { |part| points(part) }
|
|
@@ -27,13 +27,13 @@ module Stationery
|
|
|
27
27
|
ALIGN = /\A\s*(left|center|right|justify)\s*\z/i
|
|
28
28
|
# What makes a container a block of its own instead of flattening into its parent.
|
|
29
29
|
BOXED = %i[background padding padding_top padding_right padding_bottom padding_left margin margin_top
|
|
30
|
-
margin_bottom break_before break_after keep_together columns].freeze
|
|
30
|
+
margin_right margin_bottom margin_left break_before break_after keep_together columns].freeze
|
|
31
31
|
# What a block of its own takes besides.
|
|
32
32
|
WITH_BOX = %i[column_gap].freeze
|
|
33
33
|
INHERITED = %i[align].freeze
|
|
34
34
|
# What an image takes from its style, and what a floated one takes too.
|
|
35
35
|
IMAGE = %i[width align].freeze
|
|
36
|
-
FLOATED = %i[width float margin margin_top margin_bottom].freeze
|
|
36
|
+
FLOATED = %i[width float margin margin_top margin_right margin_bottom margin_left].freeze
|
|
37
37
|
EMPTY = {}.freeze
|
|
38
38
|
|
|
39
39
|
def self.convert(root, css = Css.parse([])) = new(css).blocks_of(root, EMPTY, EMPTY)
|
|
@@ -129,11 +129,15 @@ module Stationery
|
|
|
129
129
|
def image(attributes, style)
|
|
130
130
|
return unless attributes["src"]
|
|
131
131
|
|
|
132
|
-
Rich::Image.new(src: attributes["src"], alt: attributes["alt"],
|
|
132
|
+
Rich::Image.new(src: attributes["src"], alt: description(attributes["alt"]),
|
|
133
133
|
width: dimension(attributes["width"]), height: dimension(attributes["height"]),
|
|
134
134
|
style: style.slice(*(style[:float] ? FLOATED : IMAGE)))
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
# An alt that is there and empty is how HTML marks decoration (false,
|
|
138
|
+
# as `image alt: false`); one that is not there is a description missing.
|
|
139
|
+
def description(alt) = alt && !Tagging.blank?(alt) && alt
|
|
140
|
+
|
|
137
141
|
def dimension(value) = value&.[](/\A\s*(\d+)/, 1)&.to_i
|
|
138
142
|
|
|
139
143
|
def table(element, marks, inherited, style)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Layout
|
|
5
|
+
class Box < Node
|
|
6
|
+
# A box beside floats. One that paints nothing of its own and takes the
|
|
7
|
+
# width it is given is a block as CSS knows it: it keeps the full width
|
|
8
|
+
# of its flow and hands the floats to its content (`exclusions:`, from
|
|
9
|
+
# its own top), which wraps beside them and takes the full width below
|
|
10
|
+
# them. The content meets the floats inside the padding: padding that
|
|
11
|
+
# lies under a float is not added to it.
|
|
12
|
+
#
|
|
13
|
+
# Any other box is a block of the width the floats leave, all the way
|
|
14
|
+
# down: one with a background, a border, a shadow or a link, which
|
|
15
|
+
# would be painted over a float that was painted before it, and one
|
|
16
|
+
# with a width or a height of its own, a rotated or a clipped one, or
|
|
17
|
+
# one that places its content by `valign:`.
|
|
18
|
+
module Wrapping
|
|
19
|
+
def wraps? = !sized? && !painted? && @content.wraps?
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def sized?
|
|
24
|
+
!(@width_spec.nil? && @height.nil?) || !@rotate.zero? || @overflow != :visible || @valign != :top
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def painted? = !(@background || @border || @shadow || @link).nil?
|
|
28
|
+
|
|
29
|
+
# Where the content goes beside floats, and what they take from it
|
|
30
|
+
# there: measure, split and paint place it by this one slot. nil
|
|
31
|
+
# without floats.
|
|
32
|
+
def beside(width, exclusions)
|
|
33
|
+
return unless exclusions
|
|
34
|
+
|
|
35
|
+
top, right, _bottom, left = insets
|
|
36
|
+
Flow::Placement::Slot.new(top:, left:, width: inner_width(width),
|
|
37
|
+
exclusions: exclusions.inset(top:, right:, left:))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_height(width, exclusions)
|
|
41
|
+
slot = beside(width, exclusions)
|
|
42
|
+
slot ? slot.measure(@content) : @content.measure(inner_width(width))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def split_content(width, height, fresh, exclusions)
|
|
46
|
+
slot = beside(width, exclusions)
|
|
47
|
+
slot ? slot.split(@content, height, fresh:) : @content.split(inner_width(width), height, fresh:)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "box/wrapping"
|
|
4
|
+
|
|
3
5
|
module Stationery
|
|
4
6
|
module Layout
|
|
5
7
|
# A container with padding, background, border and corner radius. A box
|
|
@@ -15,6 +17,8 @@ module Stationery
|
|
|
15
17
|
# never splits); `shadow:` paints a soft drop shadow under it, taking no
|
|
16
18
|
# space; `overflow: :hidden` clips the content to the rounded outline.
|
|
17
19
|
class Box < Node
|
|
20
|
+
include Wrapping
|
|
21
|
+
|
|
18
22
|
BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
|
|
19
23
|
SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }.freeze
|
|
20
24
|
SHADOW_LAYERS = 8
|
|
@@ -68,16 +72,16 @@ module Stationery
|
|
|
68
72
|
|
|
69
73
|
def prefer_whole? = break_inside.nil? && splittable?
|
|
70
74
|
|
|
71
|
-
def split(width, height, fresh: false)
|
|
72
|
-
return [self, nil] if measure(width) <= height + EPSILON
|
|
75
|
+
def split(width, height, fresh: false, exclusions: nil)
|
|
76
|
+
return [self, nil] if measure(width, exclusions:) <= height + EPSILON
|
|
73
77
|
|
|
74
78
|
cut = @open | [:bottom]
|
|
75
79
|
available = height - vertical(cut)
|
|
76
|
-
head, tail =
|
|
80
|
+
head, tail = split_content(width, available, fresh, exclusions)
|
|
77
81
|
return [nil, self] unless head
|
|
78
|
-
return
|
|
82
|
+
return closed(width, height, head, fresh, exclusions) unless tail || @min_height.to_f > height + EPSILON
|
|
79
83
|
|
|
80
|
-
fragments(width, height, head, tail || Flow.new, cut)
|
|
84
|
+
fragments(width, height, head, tail || Flow.new, cut, exclusions)
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
# An empty continuation: open at the top, without a floor.
|
|
@@ -87,19 +91,24 @@ module Stationery
|
|
|
87
91
|
def with_content(content, open = @open) = dup.reopen(content, open)
|
|
88
92
|
def with_open(*sides) = with_content(@content, @open | sides)
|
|
89
93
|
|
|
90
|
-
def measure(width)
|
|
91
|
-
@height
|
|
94
|
+
def measure(width, exclusions: nil)
|
|
95
|
+
return @height if @height
|
|
96
|
+
|
|
97
|
+
memoize_by_width(exclusions ? [width, exclusions] : width) do
|
|
98
|
+
[content_height(width, exclusions) + vertical, @min_height || 0].max
|
|
99
|
+
end
|
|
92
100
|
end
|
|
93
101
|
|
|
94
|
-
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
95
|
-
height ||= measure(width)
|
|
102
|
+
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, exclusions: nil, **)
|
|
103
|
+
height ||= measure(width, exclusions:)
|
|
104
|
+
slot = beside(width, exclusions)
|
|
96
105
|
canvas.rotate(@rotate, around: [x + (width / 2.0), y + (height / 2.0)]) do
|
|
97
106
|
canvas.structure(@tag) do
|
|
98
107
|
canvas.structure(@link_tag) do
|
|
99
108
|
paint_shadow(canvas, x, y, width, height)
|
|
100
109
|
paint_background(canvas, x, y, width, height)
|
|
101
110
|
paint_border(canvas, x, y, width, height)
|
|
102
|
-
paint_content(canvas, x, y, width, height, valign || @valign)
|
|
111
|
+
slot ? slot.paint(@content, canvas, x, y) : paint_content(canvas, x, y, width, height, valign || @valign)
|
|
103
112
|
end
|
|
104
113
|
end
|
|
105
114
|
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
@@ -120,9 +129,26 @@ module Stationery
|
|
|
120
129
|
|
|
121
130
|
def fragment(content, open, min_height) = dup.reopen(content, open, min_height)
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
# The content ends on this page. A head is open at the bottom, so the
|
|
133
|
+
# content was cut at the height less the top of the box alone: it fits
|
|
134
|
+
# with the box closed under it (what it dropped at the cut made the
|
|
135
|
+
# room), or the padding and border that close the box do not. Then the
|
|
136
|
+
# content is cut again with their room kept, and the last of it goes to
|
|
137
|
+
# the next page with them. Content that cannot be cut there moves whole;
|
|
138
|
+
# first on a fresh page it is kept as it is, and reported.
|
|
139
|
+
def closed(width, height, head, fresh, exclusions)
|
|
140
|
+
whole = with_content(head)
|
|
141
|
+
return [whole, nil] if whole.measure(width, exclusions:) <= height + EPSILON
|
|
142
|
+
|
|
143
|
+
head, tail = split_content(width, height - vertical, fresh, exclusions)
|
|
144
|
+
return fragments(width, height, head, tail, @open | [:bottom], exclusions) if head && tail
|
|
145
|
+
|
|
146
|
+
fresh ? [whole, nil] : [nil, self]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def fragments(width, height, head, tail, cut, exclusions)
|
|
124
150
|
first = fragment(head, cut, @min_height && [@min_height, height].min).tap { |part| part.keep_with_next = nil }
|
|
125
|
-
rest = @min_height.to_f - first.measure(width)
|
|
151
|
+
rest = @min_height.to_f - first.measure(width, exclusions:)
|
|
126
152
|
[first, fragment(tail, @open | [:top], rest.positive? ? rest : nil)]
|
|
127
153
|
end
|
|
128
154
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Layout
|
|
5
|
+
class Columns
|
|
6
|
+
# Fills balanced columns evenly. Columns filled in order at the balanced
|
|
7
|
+
# height leave the last one what is left over: ten lines in three
|
|
8
|
+
# columns are 4, 4 and 2. Here the first column keeps what fits the
|
|
9
|
+
# height, what it leaves is balanced through the columns after it, and
|
|
10
|
+
# so on down to the last two: 4, 3 and 3. What cannot be shared goes to
|
|
11
|
+
# the earlier columns.
|
|
12
|
+
#
|
|
13
|
+
# Every column is cut by Flow#split from what the one before it left,
|
|
14
|
+
# so the result is a pour like any other and the break rules hold in
|
|
15
|
+
# it. Fitting is not monotonic in the height, so nothing is assumed of
|
|
16
|
+
# that pour: the columns filled in order are kept unless it places
|
|
17
|
+
# everything, is exactly as tall as they are (what follows the block
|
|
18
|
+
# stays where it is), and has no later column taller than an earlier
|
|
19
|
+
# one where they have none. They are also kept when their first column
|
|
20
|
+
# cannot be cut again below other content (a child too tall for the
|
|
21
|
+
# page).
|
|
22
|
+
#
|
|
23
|
+
# Each level is one Balancer search with a column less, so levelling
|
|
24
|
+
# pours at most (count - 2) * (MAX_PROBES + 1) times and cuts a first
|
|
25
|
+
# column count - 2 times. Two columns are even as they are: the second
|
|
26
|
+
# takes what the first left.
|
|
27
|
+
class Leveller
|
|
28
|
+
# `limit` is the tallest a column may be.
|
|
29
|
+
def initialize(pour, limit: Float::INFINITY)
|
|
30
|
+
@pour = pour
|
|
31
|
+
@limit = limit
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `poured` is the balanced pour: the columns filled in order.
|
|
35
|
+
def call(poured)
|
|
36
|
+
return poured unless poured.complete? && @pour.count > 2
|
|
37
|
+
|
|
38
|
+
height = [@limit, poured.height(@pour.width)].min
|
|
39
|
+
first, rest = @pour.first(height)
|
|
40
|
+
return poured unless first && rest
|
|
41
|
+
|
|
42
|
+
levelled = after(first, rest, height)
|
|
43
|
+
better?(levelled, poured) ? levelled : poured
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def after(first, rest, height)
|
|
49
|
+
poured = self.class.new(rest, limit: height).call(Balancer.new(rest, limit: height).call)
|
|
50
|
+
Poured.new(columns: [first, *poured.columns], rest: poured.rest)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def better?(levelled, poured)
|
|
54
|
+
levelled.complete? && (levelled.height(@pour.width) - poured.height(@pour.width)).abs <= EPSILON &&
|
|
55
|
+
(descending?(levelled) || !descending?(poured))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def descending?(poured)
|
|
59
|
+
heights = poured.columns.map { |column| column.measure(@pour.width) }
|
|
60
|
+
heights.each_cons(2).all? { |taller, shorter| shorter <= taller + EPSILON }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -41,6 +41,13 @@ module Stationery
|
|
|
41
41
|
end
|
|
42
42
|
Poured.new(columns:, rest:)
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
# [the first column at `height`, the pour of what it leaves through
|
|
46
|
+
# the columns after it (nil when it leaves nothing)].
|
|
47
|
+
def first(height)
|
|
48
|
+
head, tail = @flow.split(@width, height)
|
|
49
|
+
[head, tail && self.class.new(tail, @width, @count - 1)]
|
|
50
|
+
end
|
|
44
51
|
end
|
|
45
52
|
end
|
|
46
53
|
end
|