feat: embedded Liberation fonts on every surface; the layout engine
One font set everywhere (spec §4.2). The twelve Liberation faces ship twice — internal/slides/fonts/files for the engine and the PDF, internal/slides/static/fonts for the browser — declared as @font-face in slides.css with content-hashed URLs, and the six themes' font stacks now name them. A test holds the two copies byte-identical, one pins the hashes, and one pairs each theme's --slides-font-heading to the face fonts.Get measures with, because those two disagreeing is exactly how a PDF stops matching the screen. internal/slides/layout turns a slide into a paint list: a background and an ordered list of rects, ellipses, lines, images and positioned text runs, at 1920 by 1080. It is the only input the SVG and the PDF will have. Regions per template are one table read out of slides.css (regions.go), with a test that parses the stylesheet's grid-template-areas and holds the two to the same region names. The numbers were checked against a real Chromium rather than reasoned about, and it found two mistakes: a container query unit is a percent of the container's CONTENT box, so a cqw on a padded template is 16.8 and not 19.2 — every type size on six of seven templates was 12.5% too big — and a picture or a shape in a flow region is a flex item that SHRINKS to the space left, rather than stacking a square 1680px block three slides below the frame. That check is now a browser test. render.SlideVars and render.ThemeID are the seams the engine paints from, so the palette and the theme resolution have one home rather than two opinions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
internal/slides/fonts/files/LICENSE-OFL-1.1.txt+91 −0internal/slides/fonts/files/LiberationMono-Bold.ttfbinaryinternal/slides/fonts/files/LiberationMono-BoldItalic.ttfbinaryinternal/slides/fonts/files/LiberationMono-Italic.ttfbinaryinternal/slides/fonts/files/LiberationMono-Regular.ttfbinaryinternal/slides/fonts/files/LiberationSans-Bold.ttfbinaryinternal/slides/fonts/files/LiberationSans-BoldItalic.ttfbinaryinternal/slides/fonts/files/LiberationSans-Italic.ttfbinaryinternal/slides/fonts/files/LiberationSans-Regular.ttfbinaryinternal/slides/fonts/files/LiberationSerif-Bold.ttfbinaryinternal/slides/fonts/files/LiberationSerif-BoldItalic.ttfbinaryinternal/slides/fonts/files/LiberationSerif-Italic.ttfbinaryinternal/slides/fonts/files/LiberationSerif-Regular.ttfbinaryinternal/slides/fonts/fonts.go+313 −0internal/slides/fonts/fonts_test.go+229 −0internal/slides/fonts/truetype.go+703 −0internal/slides/layout/blocks.go+531 −0internal/slides/layout/fixture_test.go+226 −0internal/slides/layout/layout.go+499 −0internal/slides/layout/layout_test.go+576 −0internal/slides/layout/paint.go+326 −0internal/slides/layout/props.go+217 −0internal/slides/layout/regions.go+179 −0internal/slides/layout/regions_test.go+210 −0internal/slides/layout/sizes.go+177 −0internal/slides/layout/testdata/blank.golden.json+187 −0internal/slides/layout/testdata/canvas-reflow.golden.json+83 −0internal/slides/layout/testdata/canvas.golden.json+70 −0internal/slides/layout/testdata/image-full.golden.json+49 −0internal/slides/layout/testdata/quote.golden.json+46 −0internal/slides/layout/testdata/section.golden.json+29 −0internal/slides/layout/testdata/title-body.golden.json+237 −0internal/slides/layout/testdata/title.golden.json+33 −0internal/slides/layout/testdata/two-column.golden.json+124 −0internal/slides/layout/text.go+356 −0internal/slides/render/render.go+61 −26internal/slides/render/render_test.go+97 −0internal/slides/render/testdata/blank-desktop.golden.html+1 −1internal/slides/render/testdata/blank-phone.golden.html+1 −1internal/slides/render/testdata/canvas-desktop.golden.html+1 −1internal/slides/render/testdata/canvas-phone.golden.html+1 −1internal/slides/render/testdata/canvas-reflow-desktop.golden.html+1 −1internal/slides/render/testdata/canvas-reflow-phone.golden.html+1 −1internal/slides/render/testdata/image-full-desktop.golden.html+1 −1internal/slides/render/testdata/image-full-phone.golden.html+1 −1internal/slides/render/testdata/quote-desktop.golden.html+1 −1internal/slides/render/testdata/quote-phone.golden.html+1 −1internal/slides/render/testdata/section-desktop.golden.html+1 −1internal/slides/render/testdata/section-phone.golden.html+1 −1internal/slides/render/testdata/title-body-desktop.golden.html+1 −1internal/slides/render/testdata/title-body-phone.golden.html+1 −1internal/slides/render/testdata/title-desktop.golden.html+1 −1internal/slides/render/testdata/title-phone.golden.html+1 −1internal/slides/render/testdata/two-column-desktop.golden.html+1 −1internal/slides/render/testdata/two-column-phone.golden.html+1 −1internal/slides/render/themes.go+19 −7internal/slides/static/fonts/LICENSE-OFL-1.1.txt+91 −0internal/slides/static/fonts/LiberationMono-Bold.ttfbinaryinternal/slides/static/fonts/LiberationMono-BoldItalic.ttfbinaryinternal/slides/static/fonts/LiberationMono-Italic.ttfbinaryinternal/slides/static/fonts/LiberationMono-Regular.ttfbinaryinternal/slides/static/fonts/LiberationSans-Bold.ttfbinaryinternal/slides/static/fonts/LiberationSans-BoldItalic.ttfbinaryinternal/slides/static/fonts/LiberationSans-Italic.ttfbinaryinternal/slides/static/fonts/LiberationSans-Regular.ttfbinaryinternal/slides/static/fonts/LiberationSerif-Bold.ttfbinaryinternal/slides/static/fonts/LiberationSerif-BoldItalic.ttfbinaryinternal/slides/static/fonts/LiberationSerif-Italic.ttfbinaryinternal/slides/static/fonts/LiberationSerif-Regular.ttfbinaryinternal/slides/static/slides.css+124 −6internal/slidestest/browser_layout_test.go+197 −0internal/slidestest/fonts_test.go+185 −0
CI passed — run details
Intent
<task-notification> <task-id>a1174c008b67f214c</task-id> <tool-use-id>toolu_01FDjUnfnXVEufdP4WBr6o7S</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a1174c008b67f214c.output</output-file> <status>completed</status> <summary>Agent "S3 Task 1: svg2pdf wasm spike" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Committed; the other agent's uncommitted `internal/slides/**` edits are untouched. Report is at `.superpowers/sdd/2026-09-08-slice-3-export/task-1-report.md`. **VIABLE** - Commit `b04cccb` — `spike: svg2pdf under wasm32 — viable` (12 files; spec §1 row 13 added). - Module: `slides_pdf.wasm` 3,672,361 bytes (3.50 MiB), 0 host imports, byte-reproducible, no getrandom in the graph (no vendoring/patching needed). - Tests: Rust 5/5 (`no_host_imports`; text as Type0/CIDFontType2 + `FontFile2` subset `XFNOQL+LiberationSerif` + ToUnicode, `Tj` in inflated stream; 2 SVGs → 2 pages; ABI frame errors; PNG `<image>` embeds). Go wazero spike passes: compile 746 ms, render 5 ms/page, 200 pages in 140 ms at 7.6 MiB memory. - Concerns (detailed in report): font subset embedded per page, not per document (~7 KB/page/face); per-glyph `Tj` (extraction via ToUnicode, `pdftotext` not installed to confirm); only JPEG/PNG/GIF — any other raster fails the whole render, so the SVG encoder must never emit WebP; gate `.amadan/ci.d/10-gate` not yet wired for `core/pdf` (outside my file allowance; Task 4); `make ci` currently fails in `internal/slides/render` from the other agent's in-progress edits, not mine; first `cargo fetch` needed the sandbox off (read-only `~/.cargo/registry`).</result> <usage><subagent_tokens>155626</subagent_tokens><tool_uses>71</tool_uses><duration_ms>785538</duration_ms></usage> </task-notification>
Attributed to this commit: 27.9 g CO₂e (3357070 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-3-export, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/internal/slides/fonts/files/LICENSE-OFL-1.1.txt b/internal/slides/fonts/files/LICENSE-OFL-1.1.txt| new file mode 100644 |
| index 0000000..84619fc |
| --- /dev/null |
| +++ b/internal/slides/fonts/files/LICENSE-OFL-1.1.txt |
| @@ -0,0 +1,91 @@ |
| +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 |
| + |
| +PREAMBLE The goals of the Open Font License (OFL) are to stimulate |
| +worldwide development of collaborative font projects, to support the font |
| +creation efforts of academic and linguistic communities, and to provide |
| +a free and open framework in which fonts may be shared and improved in |
| +partnership with others. |
| + |
| +The OFL allows the licensed fonts to be used, studied, modified and |
| +redistributed freely as long as they are not sold by themselves. |
| +The fonts, including any derivative works, can be bundled, embedded, |
| +redistributed and/or sold with any software provided that any reserved |
| +names are not used by derivative works. The fonts and derivatives, |
| +however, cannot be released under any other type of license. The |
| +requirement for fonts to remain under this license does not apply to |
| +any document created using the fonts or their derivatives. |
| + |
| + |
| + |
| +DEFINITIONS |
| +"Font Software" refers to the set of files released by the Copyright |
| +Holder(s) under this license and clearly marked as such. |
| +This may include source files, build scripts and documentation. |
| + |
| +"Reserved Font Name" refers to any names specified as such after the |
| +copyright statement(s). |
| + |
| +"Original Version" refers to the collection of Font Software components |
| +as distributed by the Copyright Holder(s). |
| + |
| +"Modified Version" refers to any derivative made by adding to, deleting, |
| +or substituting ? in part or in whole ? |
| +any of the components of the Original Version, by changing formats or |
| +by porting the Font Software to a new environment. |
| + |
| +"Author" refers to any designer, engineer, programmer, technical writer |
| +or other person who contributed to the Font Software. |
| + |
| + |
| +PERMISSION & CONDITIONS |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a |
| +copy of the Font Software, to use, study, copy, merge, embed, modify, |
| +redistribute, and sell modified and unmodified copies of the Font |
| +Software, subject to the following conditions: |
| + |
| +1) Neither the Font Software nor any of its individual components,in |
| + Original or Modified Versions, may be sold by itself. |
| + |
| +2) Original or Modified Versions of the Font Software may be bundled, |
| + redistributed and/or sold with any software, provided that each copy |
| + contains the above copyright notice and this license. These can be |
| + included either as stand-alone text files, human-readable headers or |
| + in the appropriate machine-readable metadata fields within text or |
| + binary files as long as those fields can be easily viewed by the user. |
| + |
| +3) No Modified Version of the Font Software may use the Reserved Font |
| + Name(s) unless explicit written permission is granted by the |
| + corresponding Copyright Holder. This restriction only applies to the |
| + primary font name as presented to the users. |
| + |
| +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font |
| + Software shall not be used to promote, endorse or advertise any |
| + Modified Version, except to acknowledge the contribution(s) of the |
| + Copyright Holder(s) and the Author(s) or with their explicit written |
| + permission. |
| + |
| +5) The Font Software, modified or unmodified, in part or in whole, must |
| + be distributed entirely under this license, and must not be distributed |
| + under any other license. The requirement for fonts to remain under |
| + this license does not apply to any document created using the Font |
| + Software. |
| + |
| + |
| + |
| +TERMINATION |
| +This license becomes null and void if any of the above conditions are not met. |
| + |
| + |
| + |
| +DISCLAIMER |
| +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
| +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF |
| +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT |
| +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE |
| +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
| +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL |
| +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
| +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER |
| +DEALINGS IN THE FONT SOFTWARE. |
| + |
diff --git a/internal/slides/fonts/files/LiberationMono-Bold.ttf b/internal/slides/fonts/files/LiberationMono-Bold.ttf| new file mode 100644 |
| index 0000000..c490006 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationMono-Bold.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationMono-BoldItalic.ttf b/internal/slides/fonts/files/LiberationMono-BoldItalic.ttf| new file mode 100644 |
| index 0000000..f107ec6 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationMono-BoldItalic.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationMono-Italic.ttf b/internal/slides/fonts/files/LiberationMono-Italic.ttf| new file mode 100644 |
| index 0000000..c7fa413 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationMono-Italic.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationMono-Regular.ttf b/internal/slides/fonts/files/LiberationMono-Regular.ttf| new file mode 100644 |
| index 0000000..fa81eaa |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationMono-Regular.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSans-Bold.ttf b/internal/slides/fonts/files/LiberationSans-Bold.ttf| new file mode 100644 |
| index 0000000..31ddaef |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSans-Bold.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSans-BoldItalic.ttf b/internal/slides/fonts/files/LiberationSans-BoldItalic.ttf| new file mode 100644 |
| index 0000000..153950d |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSans-BoldItalic.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSans-Italic.ttf b/internal/slides/fonts/files/LiberationSans-Italic.ttf| new file mode 100644 |
| index 0000000..ebf48bc |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSans-Italic.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSans-Regular.ttf b/internal/slides/fonts/files/LiberationSans-Regular.ttf| new file mode 100644 |
| index 0000000..f524339 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSans-Regular.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSerif-Bold.ttf b/internal/slides/fonts/files/LiberationSerif-Bold.ttf| new file mode 100644 |
| index 0000000..8dce980 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSerif-Bold.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSerif-BoldItalic.ttf b/internal/slides/fonts/files/LiberationSerif-BoldItalic.ttf| new file mode 100644 |
| index 0000000..04c27fd |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSerif-BoldItalic.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSerif-Italic.ttf b/internal/slides/fonts/files/LiberationSerif-Italic.ttf| new file mode 100644 |
| index 0000000..01b9210 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSerif-Italic.ttf differ |
diff --git a/internal/slides/fonts/files/LiberationSerif-Regular.ttf b/internal/slides/fonts/files/LiberationSerif-Regular.ttf| new file mode 100644 |
| index 0000000..d7622a3 |
| Binary files /dev/null and b/internal/slides/fonts/files/LiberationSerif-Regular.ttf differ |
diff --git a/internal/slides/fonts/fonts.go b/internal/slides/fonts/fonts.go| new file mode 100644 |
| index 0000000..5f70cc6 |
| --- /dev/null |
| +++ b/internal/slides/fonts/fonts.go |
| @@ -0,0 +1,313 @@ |
| +// Package fonts is the one set of typefaces this app draws with, |
| +// everywhere: the browser downloads these files (internal/slides/static/fonts, |
| +// declared in slides.css), the layout engine measures with them, and the |
| +// PDF embeds them. |
| +// |
| +// That "one set" is the whole point (spec §4.2). A layout engine that |
| +// measures Liberation Sans while the projector renders whatever the |
| +// machine calls "system-ui" is not slightly off, it is measuring a |
| +// different font: the exported PDF and the slide on screen break their |
| +// lines in different places for no reason anybody can see. Divergence |
| +// between the two is confined to line breaking — this package is what |
| +// confines it. |
| +// |
| +// Liberation Serif, Sans and Mono, four faces each, SIL OFL 1.1 |
| +// (files/LICENSE-OFL-1.1.txt, redistributed with them as that licence |
| +// requires). Docs made the same choice for the same reason and this is |
| +// its font set, copied: Liberation is metric-compatible with Times New |
| +// Roman, Arial and Courier New, it is complete enough for Latin, |
| +// Greek, Cyrillic and Hebrew, and it costs nothing to redistribute. |
| +// |
| +// # TrueType, not woff2 |
| +// |
| +// These files are roughly twice the bytes a woff2 would be. Converting |
| +// them needs a compressor this build does not have and would add a |
| +// build step to a repo whose static assets are committed artifacts. A |
| +// browser fetches only the faces a page actually uses, so a |
| +// declared-but-unused face costs a reader nothing. The conversion is a |
| +// follow-up with a real trigger — the first time page weight is |
| +// measured and these show up in it. |
| +// |
| +// # Two copies of the same bytes |
| +// |
| +// files/ is embedded for the layout engine and the PDF; |
| +// ../static/fonts/ is embedded for the browser to fetch. They are the |
| +// same twelve files, and TestServedFontsMatchTheEmbeddedOnes holds |
| +// them byte-identical. //go:embed cannot reach up out of a package |
| +// directory, which is the whole reason for the second copy. |
| +package fonts |
| + |
| +import ( |
| + "embed" |
| + "fmt" |
| + "io/fs" |
| + "strings" |
| + "unicode" |
| +) |
| + |
| +//go:embed files |
| +var files embed.FS |
| + |
| +// Family is one of the three typefaces a slide is set in. |
| +type Family uint8 |
| + |
| +const ( |
| + // Sans is the body face of every theme and the heading face of |
| + // five of the six. |
| + Sans Family = iota |
| + // Serif is `paper`'s heading face — the one theme that has one. |
| + Serif |
| + // Mono is code, on every theme. |
| + Mono |
| +) |
| + |
| +// Role is what a face is being asked for, which is the question a |
| +// caller actually has: a heading, running text, or code. The family it |
| +// resolves to is the theme's business (see Get). |
| +type Role uint8 |
| + |
| +const ( |
| + Heading Role = iota |
| + Body |
| + Code |
| +) |
| + |
| +// Face is one font program: a family and a weight/slope. Three fields |
| +// and no payload — the bytes come from Data and the metrics from |
| +// Metrics, so a Face is a value a caller can compare, put in a map key |
| +// and write into a golden file. |
| +type Face struct { |
| + Family Family |
| + Bold bool |
| + Italic bool |
| +} |
| + |
| +// themeHeading is the heading family per theme, and it is the ONE fact |
| +// this package knows about themes. |
| +// |
| +// It has to agree with internal/slides/render/themes.go's |
| +// --slides-font-heading, because that is what the browser renders with |
| +// and this is what the layout engine measures with; the moment they |
| +// disagree the PDF stops matching the screen. Nothing enforces that at |
| +// compile time — render imports this package, not the other way round |
| +// — so render_test.go's TestThemeFacesMatchTheStylesheetStacks reads |
| +// each theme's stack and checks it names the family this table gives. |
| +// |
| +// Every theme's body is Sans and every theme's code is Mono, so |
| +// neither needs a table. |
| +var themeHeading = map[string]Family{ |
| + "paper": Serif, |
| + "ink": Sans, |
| + "sunset": Sans, |
| + "forest": Sans, |
| + "slate": Sans, |
| + "candy": Sans, |
| +} |
| + |
| +// Get is the face a theme sets a role in. |
| +// |
| +// theme is a resolved theme id (render.ThemeID); a name this build has |
| +// never heard of gets Sans headings rather than an error, which is the |
| +// same shape render takes with an unknown theme — a deck whose theme |
| +// was renamed out from under it still lays out. |
| +func Get(theme string, role Role, bold, italic bool) Face { |
| + switch role { |
| + case Code: |
| + return Face{Family: Mono, Bold: bold, Italic: italic} |
| + case Heading: |
| + if f, ok := themeHeading[theme]; ok { |
| + return Face{Family: f, Bold: bold, Italic: italic} |
| + } |
| + } |
| + return Face{Family: Sans, Bold: bold, Italic: italic} |
| +} |
| + |
| +// FamilyForStack maps a CSS font stack — a theme's |
| +// --slides-font-heading, or a font a deck named itself — to the |
| +// embedded family it asks for first. |
| +// |
| +// The false return is load-bearing. meta.fonts lets a deck name any |
| +// family at all (render.safeFontStack only checks the characters), and |
| +// a face this binary does not carry is a face the layout engine cannot |
| +// measure. A caller that gets false has to decide what to substitute |
| +// and to know it is substituting, rather than being handed a silent |
| +// wrong answer. |
| +func FamilyForStack(stack string) (Family, bool) { |
| + first, _, _ := strings.Cut(stack, ",") |
| + first = strings.TrimSpace(first) |
| + first = strings.Trim(first, `"'`) |
| + switch strings.ToLower(first) { |
| + case "liberation sans": |
| + return Sans, true |
| + case "liberation serif": |
| + return Serif, true |
| + case "liberation mono": |
| + return Mono, true |
| + } |
| + return 0, false |
| +} |
| + |
| +// CSSName is the family name slides.css declares this family under and |
| +// a stack names first. It is the face's real name, so a reader who |
| +// happens to have Liberation installed matches their own copy — the |
| +// metrics are the same set, which is what the name promises. |
| +func (f Family) CSSName() string { |
| + switch f { |
| + case Serif: |
| + return "Liberation Serif" |
| + case Mono: |
| + return "Liberation Mono" |
| + } |
| + return "Liberation Sans" |
| +} |
| + |
| +// Stack is the whole CSS font-family value for this family: the served |
| +// face first, then what a browser that has not finished fetching it |
| +// should use in the meantime. |
| +// |
| +// Unquoted, because these are written into a style attribute inline on |
| +// a section (render/themes.go says why) and CSS reads a family name as |
| +// a sequence of identifiers. Quoting would put a run of " in every |
| +// slide's markup and buy nothing. |
| +func (f Family) Stack() string { |
| + switch f { |
| + case Serif: |
| + return "Liberation Serif, Georgia, serif" |
| + case Mono: |
| + return "Liberation Mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" |
| + } |
| + return "Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" |
| +} |
| + |
| +// PostScriptName is the name this face is registered under inside a |
| +// PDF. It is not a display name and nothing matches on it; it exists |
| +// because a PDF font dictionary requires one and a reader shows it in |
| +// document properties. |
| +func (f Face) PostScriptName() string { |
| + name := map[Family]string{Serif: "LiberationSerif", Sans: "LiberationSans", Mono: "LiberationMono"}[f.Family] |
| + switch { |
| + case f.Bold && f.Italic: |
| + return name + "-BoldItalic" |
| + case f.Bold: |
| + return name + "-Bold" |
| + case f.Italic: |
| + return name + "-Italic" |
| + } |
| + return name + "-Regular" |
| +} |
| + |
| +// File is the face's filename, in files/ and in ../static/fonts/ both. |
| +// It is PostScriptName with the space that never was and a suffix, |
| +// which is what the upstream release calls these files. |
| +func (f Face) File() string { return f.PostScriptName() + ".ttf" } |
| + |
| +// All is the twelve faces, in a stable order: family, then weight, |
| +// then slope. |
| +func All() []Face { |
| + var out []Face |
| + for _, fam := range []Family{Sans, Serif, Mono} { |
| + for _, bold := range []bool{false, true} { |
| + for _, italic := range []bool{false, true} { |
| + out = append(out, Face{Family: fam, Bold: bold, Italic: italic}) |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// data holds every face's bytes, read once at init. |
| +var data = map[Face][]byte{} |
| + |
| +func init() { |
| + for _, f := range All() { |
| + b, err := fs.ReadFile(files, "files/"+f.File()) |
| + if err != nil { |
| + // A missing face is a build mistake, and a build mistake |
| + // that reaches a running instance shows up as a deck |
| + // exporting without its bold text rather than as an |
| + // error. Refuse to start instead. |
| + panic(fmt.Sprintf("fonts: embedded face %q missing: %v", f.File(), err)) |
| + } |
| + data[f] = b |
| + } |
| +} |
| + |
| +// Data is the face's font program. The slice is the embedded one and |
| +// callers must not write to it; every caller here reads. |
| +func Data(f Face) []byte { return data[f] } |
| + |
| +// Metrics is the face's vertical extent at size, in the same units |
| +// size is in (the layout engine works in px at a 1920-wide frame). |
| +// |
| +// desc comes back positive — the table stores it negative and every |
| +// caller wants a height — and lineHeight is the face's OWN default |
| +// leading (ascent + descent + line gap). A caller following a CSS |
| +// line-height ignores that last one and multiplies size instead, which |
| +// is what internal/slides/layout does, because the browser it has to |
| +// agree with is following the stylesheet and not the font. |
| +func Metrics(f Face, size float64) (asc, desc, lineHeight float64) { |
| + font, err := f.Parsed() |
| + if err != nil { |
| + // Unreachable while the twelve embedded faces parse, which |
| + // init has effectively proved. A plausible fallback rather |
| + // than a panic: losing a deck's export to a font table is |
| + // worse than laying one block out a little wrong. |
| + return size * 0.8, size * 0.2, size * 1.2 |
| + } |
| + upm := float64(font.UnitsPerEm) |
| + asc = float64(font.Ascender) / upm * size |
| + desc = -float64(font.Descender) / upm * size |
| + lineHeight = asc + desc + float64(font.LineGap)/upm*size |
| + return asc, desc, lineHeight |
| +} |
| + |
| +// Advance is how wide s is in this face at this size, and which of its |
| +// runes the face has no glyph for. |
| +// |
| +// The width is the sum of the per-rune advances from cmap and hmtx — |
| +// no kerning, because this parser does not read GPOS, which is also |
| +// what an unkerned browser layout of the same string is. |
| +// |
| +// A rune with no glyph is measured with .notdef's advance (what a |
| +// browser draws as a box, at a box's width) AND reported. Reported, |
| +// because §11's degrade-loudly rule applies to an export as much as to |
| +// a screen: a deck in a script Liberation does not cover should come |
| +// back with a warning saying so rather than a page of tofu nobody |
| +// explained. |
| +func Advance(f Face, size float64, s string) (width float64, missing []rune) { |
| + if s == "" { |
| + return 0, nil |
| + } |
| + font, err := f.Parsed() |
| + if err != nil { |
| + return 0, nil |
| + } |
| + upm := float64(font.UnitsPerEm) |
| + var seen map[rune]bool |
| + for _, r := range s { |
| + gid := font.GlyphIndex(r) |
| + if gid == 0 && !unicode.IsSpace(r) { |
| + if seen == nil { |
| + seen = map[rune]bool{} |
| + } |
| + if !seen[r] { |
| + seen[r] = true |
| + missing = append(missing, r) |
| + } |
| + } |
| + width += float64(font.Advance(gid)) / upm * size |
| + } |
| + return width, missing |
| +} |
| + |
| +// Licence is the SIL OFL 1.1 text these faces are redistributed |
| +// under. It ships beside them in both copies: the licence requires the |
| +// text to travel with the font software, and a font inside a Go binary |
| +// and served over HTTP is font software being distributed. |
| +func Licence() string { |
| + b, err := fs.ReadFile(files, "files/LICENSE-OFL-1.1.txt") |
| + if err != nil { |
| + panic(fmt.Sprintf("fonts: embedded licence missing: %v", err)) |
| + } |
| + return string(b) |
| +} |
diff --git a/internal/slides/fonts/fonts_test.go b/internal/slides/fonts/fonts_test.go| new file mode 100644 |
| index 0000000..51b0926 |
| --- /dev/null |
| +++ b/internal/slides/fonts/fonts_test.go |
| @@ -0,0 +1,229 @@ |
| +package fonts_test |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| +) |
| + |
| +func TestEveryFaceIsEmbeddedAndParses(t *testing.T) { |
| + all := fonts.All() |
| + if len(all) != 12 { |
| + t.Fatalf("want 12 faces (three families, four each), got %d", len(all)) |
| + } |
| + seen := map[string]bool{} |
| + for _, f := range all { |
| + if seen[f.File()] { |
| + t.Errorf("%s appears twice in All()", f.File()) |
| + } |
| + seen[f.File()] = true |
| + if len(fonts.Data(f)) == 0 { |
| + t.Errorf("%s has no bytes", f.File()) |
| + } |
| + if _, err := f.Parsed(); err != nil { |
| + t.Errorf("parse %s: %v", f.File(), err) |
| + } |
| + } |
| +} |
| + |
| +// The measurements the layout engine leans on: an ascent above the |
| +// baseline, a descent below it, and both a sane fraction of the size. |
| +func TestMetricsAreASensibleFractionOfTheSize(t *testing.T) { |
| + for _, f := range fonts.All() { |
| + asc, desc, lh := fonts.Metrics(f, 100) |
| + if asc <= 50 || asc >= 120 { |
| + t.Errorf("%s ascent at size 100 is %v", f.File(), asc) |
| + } |
| + if desc <= 5 || desc >= 50 { |
| + t.Errorf("%s descent at size 100 is %v", f.File(), desc) |
| + } |
| + if lh < asc+desc { |
| + t.Errorf("%s line height %v is under ascent+descent %v", f.File(), lh, asc+desc) |
| + } |
| + } |
| +} |
| + |
| +func TestMetricsScaleWithTheSize(t *testing.T) { |
| + f := fonts.Get("paper", fonts.Body, false, false) |
| + a1, d1, l1 := fonts.Metrics(f, 10) |
| + a2, d2, l2 := fonts.Metrics(f, 20) |
| + for _, pair := range [][2]float64{{a1, a2}, {d1, d2}, {l1, l2}} { |
| + if got, want := pair[1], pair[0]*2; got < want-1e-9 || got > want+1e-9 { |
| + t.Errorf("doubling the size gave %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestAdvanceMeasuresText(t *testing.T) { |
| + f := fonts.Get("paper", fonts.Body, false, false) |
| + empty, _ := fonts.Advance(f, 100, "") |
| + if empty != 0 { |
| + t.Errorf("the empty string is %v wide", empty) |
| + } |
| + one, missing := fonts.Advance(f, 100, "Hello, slides") |
| + if len(missing) != 0 { |
| + t.Errorf("plain ASCII reported missing glyphs: %q", missing) |
| + } |
| + if one <= 0 { |
| + t.Fatalf("a string measured %v", one) |
| + } |
| + two, _ := fonts.Advance(f, 100, "Hello, slidesHello, slides") |
| + if got, want := two, one*2; got < want-1e-6 || got > want+1e-6 { |
| + t.Errorf("twice the string measured %v, want %v", got, want) |
| + } |
| + half, _ := fonts.Advance(f, 50, "Hello, slides") |
| + if got, want := half, one/2; got < want-1e-6 || got > want+1e-6 { |
| + t.Errorf("half the size measured %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// Bold is wider than regular in the same face at the same size. It is |
| +// the cheapest possible check that a Face actually selects a font |
| +// program rather than always measuring the same one. |
| +func TestBoldMeasuresWiderThanRegular(t *testing.T) { |
| + const s = "The quick brown fox" |
| + reg, _ := fonts.Advance(fonts.Face{Family: fonts.Sans}, 100, s) |
| + bold, _ := fonts.Advance(fonts.Face{Family: fonts.Sans, Bold: true}, 100, s) |
| + if bold <= reg { |
| + t.Errorf("bold measured %v, regular %v: bold should be wider", bold, reg) |
| + } |
| +} |
| + |
| +// Every glyph in a monospaced face has the same advance, which is a |
| +// property the code block's layout relies on. |
| +func TestMonoAdvancesAreUniform(t *testing.T) { |
| + f := fonts.Face{Family: fonts.Mono} |
| + one, _ := fonts.Advance(f, 100, "i") |
| + wide, _ := fonts.Advance(f, 100, "W") |
| + if got, want := wide, one; got < want-1e-9 || got > want+1e-9 { |
| + t.Errorf("mono W is %v and i is %v", got, want) |
| + } |
| + ten, _ := fonts.Advance(f, 100, "iWiWiWiWiW") |
| + if got, want := ten, one*10; got < want-1e-6 || got > want+1e-6 { |
| + t.Errorf("ten mono glyphs measured %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// A rune Liberation has no glyph for is measured (as .notdef, which is |
| +// what a browser draws a box at) and reported, once, in order. |
| +func TestAdvanceReportsRunesTheFaceCannotDraw(t *testing.T) { |
| + f := fonts.Get("paper", fonts.Body, false, false) |
| + w, missing := fonts.Advance(f, 100, "a😀b😀漢") |
| + if w <= 0 { |
| + t.Errorf("a string with missing glyphs measured %v; it should still take room", w) |
| + } |
| + want := []rune{'😀', '漢'} |
| + if len(missing) != len(want) { |
| + t.Fatalf("missing = %q, want %q", missing, want) |
| + } |
| + for i, r := range want { |
| + if missing[i] != r { |
| + t.Errorf("missing[%d] = %q, want %q", i, missing[i], r) |
| + } |
| + } |
| +} |
| + |
| +// §2.1's six themes, and the one fact this package knows about them. |
| +func TestPaperIsTheOnlyThemeWithSerifHeadings(t *testing.T) { |
| + for _, theme := range []string{"paper", "ink", "sunset", "forest", "slate", "candy"} { |
| + want := fonts.Sans |
| + if theme == "paper" { |
| + want = fonts.Serif |
| + } |
| + if got := fonts.Get(theme, fonts.Heading, false, false).Family; got != want { |
| + t.Errorf("%s headings are family %v, want %v", theme, got, want) |
| + } |
| + if got := fonts.Get(theme, fonts.Body, false, false).Family; got != fonts.Sans { |
| + t.Errorf("%s body is family %v, want Sans", theme, got) |
| + } |
| + if got := fonts.Get(theme, fonts.Code, false, false).Family; got != fonts.Mono { |
| + t.Errorf("%s code is family %v, want Mono", theme, got) |
| + } |
| + } |
| +} |
| + |
| +func TestAnUnknownThemeStillGetsAFace(t *testing.T) { |
| + f := fonts.Get("a theme from a later build", fonts.Heading, true, true) |
| + if f.Family != fonts.Sans || !f.Bold || !f.Italic { |
| + t.Errorf("got %+v, want a bold italic Sans", f) |
| + } |
| +} |
| + |
| +func TestFamilyForStackReadsTheFirstFamily(t *testing.T) { |
| + for _, tc := range []struct { |
| + stack string |
| + want fonts.Family |
| + ok bool |
| + }{ |
| + {"Liberation Sans, system-ui, sans-serif", fonts.Sans, true}, |
| + {"Liberation Serif, Georgia, serif", fonts.Serif, true}, |
| + {`"Liberation Mono", monospace`, fonts.Mono, true}, |
| + {" liberation sans , x", fonts.Sans, true}, |
| + {"Comic Sans MS, cursive", 0, false}, |
| + {"", 0, false}, |
| + } { |
| + got, ok := fonts.FamilyForStack(tc.stack) |
| + if ok != tc.ok || (ok && got != tc.want) { |
| + t.Errorf("FamilyForStack(%q) = %v, %v; want %v, %v", tc.stack, got, ok, tc.want, tc.ok) |
| + } |
| + } |
| +} |
| + |
| +// Every family's own stack resolves back to it: the CSS the themes |
| +// write and the measurement this package makes name one face. |
| +func TestEveryStackResolvesToItsOwnFamily(t *testing.T) { |
| + for _, fam := range []fonts.Family{fonts.Sans, fonts.Serif, fonts.Mono} { |
| + got, ok := fonts.FamilyForStack(fam.Stack()) |
| + if !ok || got != fam { |
| + t.Errorf("%s's stack %q resolves to %v, %v", fam.CSSName(), fam.Stack(), got, ok) |
| + } |
| + } |
| +} |
| + |
| +// The two copies of the same twelve files (this package's header says |
| +// why there are two) must be the same bytes, or the browser renders |
| +// one font set and the exporter measures another. |
| +func TestServedFontsMatchTheEmbeddedOnes(t *testing.T) { |
| + for _, f := range fonts.All() { |
| + served, err := os.ReadFile(filepath.Join("..", "static", "fonts", f.File())) |
| + if err != nil { |
| + t.Errorf("static/fonts/%s: %v", f.File(), err) |
| + continue |
| + } |
| + if !bytes.Equal(served, fonts.Data(f)) { |
| + t.Errorf("static/fonts/%s differs from the embedded copy", f.File()) |
| + } |
| + } |
| + licence, err := os.ReadFile(filepath.Join("..", "static", "fonts", "LICENSE-OFL-1.1.txt")) |
| + if err != nil { |
| + t.Fatalf("the licence has to travel with the served fonts: %v", err) |
| + } |
| + if string(licence) != fonts.Licence() { |
| + t.Error("the served licence differs from the embedded one") |
| + } |
| +} |
| + |
| +func TestLicenceIsTheOFL(t *testing.T) { |
| + if got := fonts.Licence(); !bytes.Contains([]byte(got), []byte("SIL OPEN FONT LICENSE Version 1.1")) { |
| + t.Errorf("the licence text does not name the OFL 1.1 (%d bytes)", len(got)) |
| + } |
| +} |
| + |
| +func TestPostScriptNamesAreTheUpstreamOnes(t *testing.T) { |
| + for _, tc := range []struct { |
| + face fonts.Face |
| + want string |
| + }{ |
| + {fonts.Face{Family: fonts.Sans}, "LiberationSans-Regular"}, |
| + {fonts.Face{Family: fonts.Serif, Bold: true}, "LiberationSerif-Bold"}, |
| + {fonts.Face{Family: fonts.Mono, Italic: true}, "LiberationMono-Italic"}, |
| + {fonts.Face{Family: fonts.Serif, Bold: true, Italic: true}, "LiberationSerif-BoldItalic"}, |
| + } { |
| + if got := tc.face.PostScriptName(); got != tc.want { |
| + t.Errorf("PostScriptName = %q, want %q", got, tc.want) |
| + } |
| + } |
| +} |
diff --git a/internal/slides/fonts/truetype.go b/internal/slides/fonts/truetype.go| new file mode 100644 |
| index 0000000..e4ff3c8 |
| --- /dev/null |
| +++ b/internal/slides/fonts/truetype.go |
| @@ -0,0 +1,703 @@ |
| +// truetype.go is CARLOS Docs' internal/fonts/truetype.go, copied. |
| +// |
| +// Origin: /home/paulca/amadan.net/oficina/docs/internal/fonts/truetype.go |
| +// (amadan.net/oficina/docs), taken on 2026-09-08 at slice 3. It is a |
| +// copy and not an import because the two apps are two repositories and |
| +// nothing shared exists yet to hold it; extracting this and Docs' PDF |
| +// writer into one module is slice 4's problem (the plan says so). Keep |
| +// the two in step by hand until then: a fix here is a fix over there. |
| +// |
| +// Changed from the original: Face.Parsed reads its bytes through |
| +// Data(face) rather than a Data field, because this package's Face is |
| +// three fields and no payload (fonts.go says why). |
| + |
| +package fonts |
| + |
| +import ( |
| + "encoding/binary" |
| + "errors" |
| + "fmt" |
| + "sort" |
| + "sync" |
| +) |
| + |
| +// A minimal TrueType reader and subsetter — enough to measure text and |
| +// to embed a face in a PDF, and deliberately not one byte more. |
| +// |
| +// It reads the six tables a CIDFontType2 embedding actually requires |
| +// (head, hhea, maxp, hmtx, loca, glyf) plus cmap to find glyphs by |
| +// rune, and post/OS2 for the two descriptor fields a PDF reader wants. |
| +// It does not read GPOS, so it does not kern; text measured here is |
| +// the sum of advance widths, which is what an unkerned browser layout |
| +// of the same string also is. It does not read GSUB, so no ligatures |
| +// and no complex-script shaping — a document in a script that needs |
| +// shaping lays out wrongly here, which is the same limitation §11.1 |
| +// already records for the scripts outside the core set. |
| +// |
| +// # Why subsetting keeps the original glyph ids |
| +// |
| +// The usual subsetter renumbers glyphs densely and rewrites every |
| +// reference to them. This one keeps every glyph id exactly where it |
| +// was and simply empties the glyphs nothing uses: `loca` still has |
| +// numGlyphs+1 entries, but an unused glyph's two entries are equal, so |
| +// it occupies no bytes in `glyf`. |
| +// |
| +// That is a real subset — a document using eighty glyphs of Liberation |
| +// Serif embeds about 25KB rather than 385KB — and it removes the two |
| +// places renumbering goes wrong: composite glyphs referring to their |
| +// components by id (no remapping needed, because ids do not move), and |
| +// the PDF's CIDToGIDMap (Identity, because a CID *is* the glyph id). |
| +// What it costs is `hmtx` and `loca`, which stay proportional to the |
| +// font's full glyph count rather than the subset's. For a 2,500-glyph |
| +// face that is about 15KB — worth paying to not have a subsetter whose |
| +// failure mode is a document that renders with the wrong letters. |
| + |
| +var ( |
| + errShortTable = errors.New("fonts: truetype table is shorter than its own header claims") |
| + errNoTable = errors.New("fonts: truetype table missing") |
| +) |
| + |
| +// Font is a parsed face. |
| +type Font struct { |
| + data []byte |
| + tables map[string][]byte |
| + |
| + UnitsPerEm uint16 |
| + NumGlyphs uint16 |
| + |
| + // Ascender, Descender and LineGap are hhea's, in font units. |
| + // Descender is negative, as the table stores it. |
| + Ascender int16 |
| + Descender int16 |
| + LineGap int16 |
| + |
| + // CapHeight and ItalicAngle are for the PDF font descriptor. Both |
| + // have defaults that are wrong-but-harmless if the source tables |
| + // are absent (a reader uses them for substitution decisions it |
| + // will never have to make, because the font is embedded). |
| + CapHeight int16 |
| + ItalicAngle float64 |
| + BBox [4]int16 |
| + |
| + indexToLocFormat int16 |
| + numberOfHMetrics uint16 |
| + |
| + advances []uint16 // one per glyph id, font units |
| + cmap map[rune]uint16 |
| +} |
| + |
| +func be16(b []byte, off int) (uint16, bool) { |
| + if off < 0 || off+2 > len(b) { |
| + return 0, false |
| + } |
| + return binary.BigEndian.Uint16(b[off:]), true |
| +} |
| + |
| +func be32(b []byte, off int) (uint32, bool) { |
| + if off < 0 || off+4 > len(b) { |
| + return 0, false |
| + } |
| + return binary.BigEndian.Uint32(b[off:]), true |
| +} |
| + |
| +// Parse reads the tables this package needs out of a TrueType file. |
| +// |
| +// Every read is bounds-checked even though the only fonts reaching it |
| +// are the twelve embedded in this binary: a parser that is careful |
| +// only about untrusted input is a parser nobody may ever point at |
| +// untrusted input, and this one is one `//go:embed` away from being |
| +// handed a font a person uploaded. |
| +func Parse(data []byte) (*Font, error) { |
| + if len(data) < 12 { |
| + return nil, errShortTable |
| + } |
| + switch binary.BigEndian.Uint32(data) { |
| + case 0x00010000, 0x74727565: // 'true' |
| + default: |
| + return nil, fmt.Errorf("fonts: not a TrueType outline font (sfnt version %#x)", binary.BigEndian.Uint32(data)) |
| + } |
| + numTables := int(binary.BigEndian.Uint16(data[4:])) |
| + tables := make(map[string][]byte, numTables) |
| + for i := 0; i < numTables; i++ { |
| + rec := 12 + i*16 |
| + if rec+16 > len(data) { |
| + return nil, errShortTable |
| + } |
| + tag := string(data[rec : rec+4]) |
| + off := int(binary.BigEndian.Uint32(data[rec+8:])) |
| + length := int(binary.BigEndian.Uint32(data[rec+12:])) |
| + if off < 0 || length < 0 || off+length > len(data) { |
| + return nil, fmt.Errorf("fonts: table %q runs past the end of the file", tag) |
| + } |
| + tables[tag] = data[off : off+length] |
| + } |
| + |
| + f := &Font{data: data, tables: tables} |
| + |
| + head, ok := tables["head"] |
| + if !ok || len(head) < 54 { |
| + return nil, fmt.Errorf("%w: head", errNoTable) |
| + } |
| + f.UnitsPerEm = binary.BigEndian.Uint16(head[18:]) |
| + if f.UnitsPerEm == 0 { |
| + return nil, errors.New("fonts: head.unitsPerEm is zero") |
| + } |
| + for i := 0; i < 4; i++ { |
| + f.BBox[i] = int16(binary.BigEndian.Uint16(head[36+i*2:])) |
| + } |
| + f.indexToLocFormat = int16(binary.BigEndian.Uint16(head[50:])) |
| + |
| + hhea, ok := tables["hhea"] |
| + if !ok || len(hhea) < 36 { |
| + return nil, fmt.Errorf("%w: hhea", errNoTable) |
| + } |
| + f.Ascender = int16(binary.BigEndian.Uint16(hhea[4:])) |
| + f.Descender = int16(binary.BigEndian.Uint16(hhea[6:])) |
| + f.LineGap = int16(binary.BigEndian.Uint16(hhea[8:])) |
| + f.numberOfHMetrics = binary.BigEndian.Uint16(hhea[34:]) |
| + |
| + maxp, ok := tables["maxp"] |
| + if !ok || len(maxp) < 6 { |
| + return nil, fmt.Errorf("%w: maxp", errNoTable) |
| + } |
| + f.NumGlyphs = binary.BigEndian.Uint16(maxp[4:]) |
| + |
| + if os2, ok := tables["OS/2"]; ok && len(os2) >= 90 { |
| + f.CapHeight = int16(binary.BigEndian.Uint16(os2[88:])) |
| + } |
| + if f.CapHeight == 0 { |
| + // sCapHeight is only present from OS/2 version 2. The ascender |
| + // overstates cap height slightly; a PDF reader uses this for |
| + // substitution metrics it never needs for an embedded font. |
| + f.CapHeight = f.Ascender |
| + } |
| + if post, ok := tables["post"]; ok && len(post) >= 12 { |
| + // Fixed 16.16 signed. |
| + f.ItalicAngle = float64(int32(binary.BigEndian.Uint32(post[4:]))) / 65536.0 |
| + } |
| + |
| + if err := f.parseHmtx(); err != nil { |
| + return nil, err |
| + } |
| + if err := f.parseCmap(); err != nil { |
| + return nil, err |
| + } |
| + return f, nil |
| +} |
| + |
| +func (f *Font) parseHmtx() error { |
| + hmtx, ok := f.tables["hmtx"] |
| + if !ok { |
| + return fmt.Errorf("%w: hmtx", errNoTable) |
| + } |
| + n := int(f.numberOfHMetrics) |
| + if n == 0 || n > int(f.NumGlyphs) { |
| + return errors.New("fonts: hhea.numberOfHMetrics disagrees with maxp.numGlyphs") |
| + } |
| + f.advances = make([]uint16, f.NumGlyphs) |
| + var last uint16 |
| + for i := 0; i < int(f.NumGlyphs); i++ { |
| + if i < n { |
| + v, ok := be16(hmtx, i*4) |
| + if !ok { |
| + return errShortTable |
| + } |
| + last = v |
| + } |
| + // Past numberOfHMetrics every glyph repeats the last advance — |
| + // that is what the table's compressed tail means, not "no |
| + // advance". |
| + f.advances[i] = last |
| + } |
| + return nil |
| +} |
| + |
| +// parseCmap picks the best Unicode subtable it understands and expands |
| +// it into a map. Expanding eagerly rather than searching per lookup: |
| +// a face has a few thousand mapped runes, a document has thousands of |
| +// characters, and the map is built once per face per process. |
| +func (f *Font) parseCmap() error { |
| + cm, ok := f.tables["cmap"] |
| + if !ok || len(cm) < 4 { |
| + // Not an error. A font whose glyphs are addressed by id rather |
| + // than by rune needs no cmap and is required to carry none — |
| + // which is exactly what Subset produces, since a PDF |
| + // CIDFontType2 under Identity-H looks glyphs up by id. Parse |
| + // has to be able to read its own output back, which is how the |
| + // subsetter is tested at all. |
| + // |
| + // The risk this accepts is a *primary* face arriving without a |
| + // usable cmap and measuring every character as .notdef in |
| + // silence. That is caught where it can actually be judged: |
| + // fonts_test.go's TestEveryFaceParses asserts each of the |
| + // twelve embedded faces resolves ordinary prose to real glyph |
| + // ids, which a cmap-less face cannot do. |
| + f.cmap = map[rune]uint16{} |
| + return nil |
| + } |
| + n := int(binary.BigEndian.Uint16(cm[2:])) |
| + |
| + // Preference order: a full-repertoire subtable first, then a BMP |
| + // one. (3,10) and (0,4)/(0,6) are format 12; (3,1) and (0,3) are |
| + // format 4. |
| + best, bestScore := -1, -1 |
| + for i := 0; i < n; i++ { |
| + rec := 4 + i*8 |
| + plat, ok1 := be16(cm, rec) |
| + enc, ok2 := be16(cm, rec+2) |
| + off, ok3 := be32(cm, rec+4) |
| + if !ok1 || !ok2 || !ok3 || int(off) >= len(cm) { |
| + continue |
| + } |
| + score := -1 |
| + switch { |
| + case plat == 3 && enc == 10: |
| + score = 4 |
| + case plat == 0 && (enc == 4 || enc == 6): |
| + score = 3 |
| + case plat == 3 && enc == 1: |
| + score = 2 |
| + case plat == 0: |
| + score = 1 |
| + } |
| + if score > bestScore { |
| + best, bestScore = int(off), score |
| + } |
| + } |
| + if best < 0 { |
| + return errors.New("fonts: cmap has no Unicode subtable this build understands") |
| + } |
| + sub := cm[best:] |
| + format, ok := be16(sub, 0) |
| + if !ok { |
| + return errShortTable |
| + } |
| + f.cmap = make(map[rune]uint16, 1024) |
| + switch format { |
| + case 4: |
| + return f.parseCmap4(sub) |
| + case 12: |
| + return f.parseCmap12(sub) |
| + default: |
| + return fmt.Errorf("fonts: cmap subtable format %d is not supported", format) |
| + } |
| +} |
| + |
| +func (f *Font) parseCmap4(sub []byte) error { |
| + segX2, ok := be16(sub, 6) |
| + if !ok || segX2%2 != 0 { |
| + return errShortTable |
| + } |
| + seg := int(segX2 / 2) |
| + endBase := 14 |
| + startBase := endBase + seg*2 + 2 |
| + deltaBase := startBase + seg*2 |
| + rangeBase := deltaBase + seg*2 |
| + if rangeBase+seg*2 > len(sub) { |
| + return errShortTable |
| + } |
| + for i := 0; i < seg; i++ { |
| + end := binary.BigEndian.Uint16(sub[endBase+i*2:]) |
| + start := binary.BigEndian.Uint16(sub[startBase+i*2:]) |
| + delta := binary.BigEndian.Uint16(sub[deltaBase+i*2:]) |
| + ro := binary.BigEndian.Uint16(sub[rangeBase+i*2:]) |
| + if start > end { |
| + continue |
| + } |
| + for c := uint32(start); c <= uint32(end); c++ { |
| + if c == 0xFFFF { |
| + continue // the required terminating segment, not a character |
| + } |
| + var gid uint16 |
| + if ro == 0 { |
| + gid = uint16(c) + delta |
| + } else { |
| + // The idRangeOffset trick: the offset is measured from |
| + // the address of the idRangeOffset entry itself. |
| + at := rangeBase + i*2 + int(ro) + int(c-uint32(start))*2 |
| + g, ok := be16(sub, at) |
| + if !ok { |
| + continue |
| + } |
| + if g == 0 { |
| + continue |
| + } |
| + gid = g + delta |
| + } |
| + if gid != 0 && gid < f.NumGlyphs { |
| + f.cmap[rune(c)] = gid |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (f *Font) parseCmap12(sub []byte) error { |
| + nGroups, ok := be32(sub, 12) |
| + if !ok { |
| + return errShortTable |
| + } |
| + // A group table that does not fit is the end of the table, not a |
| + // row to skip: nGroups is a number from the file, and a hostile one |
| + // of four billion against a short table would otherwise spin four |
| + // billion no-op iterations. Bounded by the bytes actually present |
| + // rather than by the count the file claims. |
| + if max := (len(sub) - 16) / 12; max >= 0 && nGroups > uint32(max) { |
| + nGroups = uint32(max) |
| + } |
| + for i := uint32(0); i < nGroups; i++ { |
| + at := 16 + int(i)*12 |
| + start, ok1 := be32(sub, at) |
| + end, ok2 := be32(sub, at+4) |
| + gid, ok3 := be32(sub, at+8) |
| + if !ok1 || !ok2 || !ok3 { |
| + break // the table ended mid-group; nothing after it is readable |
| + } |
| + if start > end { |
| + continue |
| + } |
| + // A pathological group spanning the whole of Unicode would |
| + // otherwise build a map with a million entries. |
| + if end-start > 0x10FFFF { |
| + continue |
| + } |
| + for c := start; c <= end; c++ { |
| + g := gid + (c - start) |
| + if g != 0 && g < uint32(f.NumGlyphs) { |
| + f.cmap[rune(c)] = uint16(g) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// glyphData returns the bytes of one glyph's outline, or nil for a |
| +// glyph with none (a space, or one this subsetter emptied). |
| +func (f *Font) glyphData(gid uint16) []byte { |
| + loca, err := f.locaOffsets() |
| + if err != nil { |
| + return nil |
| + } |
| + glyf, ok := f.tables["glyf"] |
| + if !ok || int(gid)+1 >= len(loca) { |
| + return nil |
| + } |
| + start, end := loca[gid], loca[gid+1] |
| + if start >= end || int(end) > len(glyf) { |
| + return nil |
| + } |
| + return glyf[start:end] |
| +} |
| + |
| +// GlyphLen is how many bytes glyph gid occupies — zero for a glyph |
| +// with no outline, which is what a subsetted-away glyph looks like. |
| +func (f *Font) GlyphLen(gid uint16) int { return len(f.glyphData(gid)) } |
| + |
| +// GlyphIsComposite reports whether gid is assembled from other glyphs. |
| +func (f *Font) GlyphIsComposite(gid uint16) bool { |
| + g := f.glyphData(gid) |
| + return len(g) >= 2 && int16(binary.BigEndian.Uint16(g)) < 0 |
| +} |
| + |
| +// GlyphIndex maps a rune to a glyph id, or 0 (.notdef) if the face has |
| +// no glyph for it. Callers decide what a 0 means: internal/pdf reports |
| +// it as a substitution in the export rather than silently drawing an |
| +// empty box, which is §11.2's degrade-loudly rule. |
| +func (f *Font) GlyphIndex(r rune) uint16 { return f.cmap[r] } |
| + |
| +// Advance is the glyph's advance width in font units. |
| +func (f *Font) Advance(gid uint16) uint16 { |
| + if int(gid) >= len(f.advances) { |
| + return 0 |
| + } |
| + return f.advances[gid] |
| +} |
| + |
| +// WidthEm is the advance of a run of text as a multiple of the em, so |
| +// a caller multiplies by a point size and gets points. Returns the |
| +// glyph ids it used, so the caller can accumulate a subset without |
| +// walking the string twice. |
| +func (f *Font) WidthEm(s string) (width float64, gids []uint16) { |
| + upm := float64(f.UnitsPerEm) |
| + for _, r := range s { |
| + gid := f.GlyphIndex(r) |
| + gids = append(gids, gid) |
| + width += float64(f.Advance(gid)) / upm |
| + } |
| + return width, gids |
| +} |
| + |
| +// locaOffsets reads loca into a plain slice of byte offsets into glyf. |
| +func (f *Font) locaOffsets() ([]uint32, error) { |
| + loca, ok := f.tables["loca"] |
| + if !ok { |
| + return nil, fmt.Errorf("%w: loca", errNoTable) |
| + } |
| + n := int(f.NumGlyphs) + 1 |
| + out := make([]uint32, n) |
| + if f.indexToLocFormat == 0 { |
| + if len(loca) < n*2 { |
| + return nil, errShortTable |
| + } |
| + for i := 0; i < n; i++ { |
| + // The short format stores offset/2. |
| + out[i] = uint32(binary.BigEndian.Uint16(loca[i*2:])) * 2 |
| + } |
| + return out, nil |
| + } |
| + if len(loca) < n*4 { |
| + return nil, errShortTable |
| + } |
| + for i := 0; i < n; i++ { |
| + out[i] = binary.BigEndian.Uint32(loca[i*4:]) |
| + } |
| + return out, nil |
| +} |
| + |
| +// componentsOf returns the glyph ids a composite glyph is built from. |
| +// A subset that keeps "é" but drops the "e" and the acute it is |
| +// assembled from renders as nothing at all, which is the failure this |
| +// exists to prevent. |
| +func componentsOf(glyph []byte) []uint16 { |
| + if len(glyph) < 10 { |
| + return nil |
| + } |
| + if int16(binary.BigEndian.Uint16(glyph)) >= 0 { |
| + return nil // simple glyph, no components |
| + } |
| + var out []uint16 |
| + at := 10 |
| + for { |
| + flags, ok1 := be16(glyph, at) |
| + gid, ok2 := be16(glyph, at+2) |
| + if !ok1 || !ok2 { |
| + return out |
| + } |
| + out = append(out, gid) |
| + at += 4 |
| + if flags&0x0001 != 0 { // ARG_1_AND_2_ARE_WORDS |
| + at += 4 |
| + } else { |
| + at += 2 |
| + } |
| + switch { |
| + case flags&0x0008 != 0: // WE_HAVE_A_SCALE |
| + at += 2 |
| + case flags&0x0040 != 0: // WE_HAVE_AN_X_AND_Y_SCALE |
| + at += 4 |
| + case flags&0x0080 != 0: // WE_HAVE_A_TWO_BY_TWO |
| + at += 8 |
| + } |
| + if flags&0x0020 == 0 { // MORE_COMPONENTS |
| + return out |
| + } |
| + } |
| +} |
| + |
| +// Subset returns a TrueType file containing only the glyphs in keep |
| +// (plus .notdef and, transitively, the components of any composite |
| +// glyph kept). Glyph ids are unchanged — see this file's header for |
| +// why that is the whole design. |
| +func (f *Font) Subset(keep map[uint16]bool) ([]byte, error) { |
| + loca, err := f.locaOffsets() |
| + if err != nil { |
| + return nil, err |
| + } |
| + glyf, ok := f.tables["glyf"] |
| + if !ok { |
| + return nil, fmt.Errorf("%w: glyf", errNoTable) |
| + } |
| + |
| + glyphAt := func(gid uint16) []byte { |
| + if int(gid)+1 >= len(loca) { |
| + return nil |
| + } |
| + start, end := loca[gid], loca[gid+1] |
| + if start >= end || int(end) > len(glyf) { |
| + return nil // an empty glyph, e.g. the space |
| + } |
| + return glyf[start:end] |
| + } |
| + |
| + // Close over composites. A worklist rather than recursion: the |
| + // component graph in a hostile font can be cyclic, and `seen` |
| + // terminates either way. |
| + seen := map[uint16]bool{0: true} // .notdef always ships |
| + work := []uint16{0} |
| + for gid := range keep { |
| + if !seen[gid] { |
| + seen[gid] = true |
| + work = append(work, gid) |
| + } |
| + } |
| + for len(work) > 0 { |
| + gid := work[len(work)-1] |
| + work = work[:len(work)-1] |
| + for _, c := range componentsOf(glyphAt(gid)) { |
| + if !seen[c] && int(c) < int(f.NumGlyphs) { |
| + seen[c] = true |
| + work = append(work, c) |
| + } |
| + } |
| + } |
| + |
| + // Rebuild glyf and loca. Every glyph keeps its id; the ones not in |
| + // `seen` get a zero-length entry. |
| + var newGlyf []byte |
| + newLoca := make([]uint32, int(f.NumGlyphs)+1) |
| + for gid := 0; gid < int(f.NumGlyphs); gid++ { |
| + newLoca[gid] = uint32(len(newGlyf)) |
| + if seen[uint16(gid)] { |
| + g := glyphAt(uint16(gid)) |
| + newGlyf = append(newGlyf, g...) |
| + for len(newGlyf)%4 != 0 { // glyphs are 4-byte aligned |
| + newGlyf = append(newGlyf, 0) |
| + } |
| + } |
| + } |
| + newLoca[f.NumGlyphs] = uint32(len(newGlyf)) |
| + |
| + // Long loca throughout: the short format cannot express an offset |
| + // above 131,070 and cannot express an odd one at all, and choosing |
| + // per-font would mean two code paths where one is barely exercised. |
| + locaBytes := make([]byte, len(newLoca)*4) |
| + for i, off := range newLoca { |
| + binary.BigEndian.PutUint32(locaBytes[i*4:], off) |
| + } |
| + |
| + head := append([]byte(nil), f.tables["head"]...) |
| + binary.BigEndian.PutUint16(head[50:], 1) // indexToLocFormat: long |
| + binary.BigEndian.PutUint32(head[8:], 0) // checkSumAdjustment, set below |
| + |
| + out := map[string][]byte{ |
| + "head": head, |
| + "hhea": f.tables["hhea"], |
| + "maxp": f.tables["maxp"], |
| + "hmtx": f.tables["hmtx"], |
| + "loca": locaBytes, |
| + "glyf": newGlyf, |
| + } |
| + |
| + // The hinting tables travel with the glyphs, when the font has |
| + // them. ISO 32000-1 §9.9 requires `cvt `, `fpgm` and `prep` to be |
| + // present when the glyph instructions need them, and Liberation's |
| + // do: every glyph here carries bytecode that calls functions |
| + // defined in `fpgm` and reads values out of `cvt `. Shipping the |
| + // outlines without them leaves a reader executing instructions |
| + // against definitions that are not in the file — most fall back to |
| + // unhinted rendering and it merely looks softer, but a strict |
| + // rasterizer or a printer RIP is entitled to do worse, and the |
| + // file is not conformant either way. |
| + // |
| + // About 15KB a face, which is the cheapest correctness in this |
| + // package. They are copied verbatim: they are indexed by nothing |
| + // this subsetter renumbers, because glyph ids do not move. |
| + for _, tag := range []string{"cvt ", "fpgm", "prep"} { |
| + if b, ok := f.tables[tag]; ok && len(b) > 0 { |
| + out[tag] = b |
| + } |
| + } |
| + for tag, b := range out { |
| + if b == nil { |
| + return nil, fmt.Errorf("%w: %s", errNoTable, tag) |
| + } |
| + } |
| + return buildSfnt(out), nil |
| +} |
| + |
| +// buildSfnt assembles a TrueType file from a tag→bytes map. |
| +func buildSfnt(tables map[string][]byte) []byte { |
| + tags := make([]string, 0, len(tables)) |
| + for t := range tables { |
| + tags = append(tags, t) |
| + } |
| + sort.Strings(tags) // the table directory must be in tag order |
| + |
| + n := len(tags) |
| + // searchRange/entrySelector/rangeShift are a binary-search hint a |
| + // modern reader ignores but a strict validator checks. |
| + entrySelector := 0 |
| + for 1<<(entrySelector+1) <= n { |
| + entrySelector++ |
| + } |
| + searchRange := 16 * (1 << entrySelector) |
| + rangeShift := 16*n - searchRange |
| + |
| + header := make([]byte, 12+16*n) |
| + binary.BigEndian.PutUint32(header, 0x00010000) |
| + binary.BigEndian.PutUint16(header[4:], uint16(n)) |
| + binary.BigEndian.PutUint16(header[6:], uint16(searchRange)) |
| + binary.BigEndian.PutUint16(header[8:], uint16(entrySelector)) |
| + binary.BigEndian.PutUint16(header[10:], uint16(rangeShift)) |
| + |
| + body := []byte{} |
| + offset := len(header) |
| + for i, tag := range tags { |
| + b := tables[tag] |
| + rec := 12 + i*16 |
| + copy(header[rec:], tag) |
| + binary.BigEndian.PutUint32(header[rec+4:], tableChecksum(b)) |
| + binary.BigEndian.PutUint32(header[rec+8:], uint32(offset+len(body))) |
| + binary.BigEndian.PutUint32(header[rec+12:], uint32(len(b))) |
| + body = append(body, b...) |
| + for len(body)%4 != 0 { |
| + body = append(body, 0) |
| + } |
| + } |
| + |
| + file := append(header, body...) |
| + |
| + // head.checkSumAdjustment: 0xB1B0AFBA minus the checksum of the |
| + // whole file computed with this field zeroed, which it is. |
| + for i, tag := range tags { |
| + if tag != "head" { |
| + continue |
| + } |
| + headOff := int(binary.BigEndian.Uint32(file[12+i*16+8:])) |
| + if headOff+12 <= len(file) { |
| + adj := 0xB1B0AFBA - tableChecksum(file) |
| + binary.BigEndian.PutUint32(file[headOff+8:], adj) |
| + } |
| + } |
| + return file |
| +} |
| + |
| +func tableChecksum(b []byte) uint32 { |
| + var sum uint32 |
| + for i := 0; i+4 <= len(b); i += 4 { |
| + sum += binary.BigEndian.Uint32(b[i:]) |
| + } |
| + // A table's length is padded to four bytes for checksum purposes |
| + // even when the bytes themselves are not present. |
| + if rem := len(b) % 4; rem != 0 { |
| + var tail [4]byte |
| + copy(tail[:], b[len(b)-rem:]) |
| + sum += binary.BigEndian.Uint32(tail[:]) |
| + } |
| + return sum |
| +} |
| + |
| +// --- parsed-face cache --- |
| + |
| +var ( |
| + parsedMu sync.Mutex |
| + parsed = map[string]*Font{} |
| +) |
| + |
| +// Parsed returns this face's parsed font, cached per process. Parsing |
| +// expands the cmap into a map of a few thousand entries, which is |
| +// cheap once and pointless twelve times a request. |
| +func (f Face) Parsed() (*Font, error) { |
| + name := f.File() |
| + parsedMu.Lock() |
| + defer parsedMu.Unlock() |
| + if font, ok := parsed[name]; ok { |
| + return font, nil |
| + } |
| + font, err := Parse(Data(f)) |
| + if err != nil { |
| + return nil, fmt.Errorf("fonts: parse %s: %w", name, err) |
| + } |
| + parsed[name] = font |
| + return font, nil |
| +} |
diff --git a/internal/slides/layout/blocks.go b/internal/slides/layout/blocks.go| new file mode 100644 |
| index 0000000..4b85f2c |
| --- /dev/null |
| +++ b/internal/slides/layout/blocks.go |
| @@ -0,0 +1,531 @@ |
| +package layout |
| + |
| +import ( |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| +) |
| + |
| +// blocks.go is one function per block type in §2.4's vocabulary. Each |
| +// measures the block at the width it has been given and hands back a |
| +// plan; nothing is positioned until the region or the canvas box says |
| +// where the top of it goes. |
| +// |
| +// Every size, colour and gap in here is slides.css's, evaluated at |
| +// 1920 (sizes.go). Where this file departs from the stylesheet it says |
| +// so on the spot, and there is one such place: a shape's label is |
| +// drawn (planShape says why). |
| + |
| +// slot is what a block is being asked to fit: the column width, a |
| +// height it must fill (a canvas box, or image-full's media region; |
| +// zero means "as tall as your content"), and the inline alignment it |
| +// inherits. |
| +type slot struct { |
| + w, h float64 |
| + align Align |
| +} |
| + |
| +// plan is a measured block: how tall it turned out, how wide its |
| +// content actually is, and how to draw it once its top-left is known. |
| +// A plan with no paint draws nothing (a spacer, an unknown type). |
| +type plan struct { |
| + h, w float64 |
| + // flex says the block can be shrunk to fit the space that is left |
| + // (a picture and a shape can; wrapped text cannot), and minH is |
| + // the floor it shrinks to. See engine.shrink. |
| + flex bool |
| + minH float64 |
| + paint func(x, y, w, h float64) []Op |
| +} |
| + |
| +// plan measures one block. byID and done are the columns machinery: |
| +// a columns block lays its children out itself, and done stops any |
| +// block being drawn twice. |
| +func (e *engine) plan(b deck.Block, s slot, byID map[string]deck.Block, done map[string]bool) plan { |
| + e.block = b.ID |
| + switch b.Type { |
| + case "heading": |
| + return e.planHeading(b, s) |
| + case "text": |
| + return e.planText(b, s) |
| + case "list": |
| + return e.planList(b, s) |
| + case "quote": |
| + return e.planQuote(b, s) |
| + case "code": |
| + return e.planCode(b, s) |
| + case "table": |
| + return e.planTable(b, s) |
| + case "shape": |
| + return e.planShape(b, s) |
| + case "image": |
| + return e.planImage(b, s) |
| + case "divider": |
| + return e.planDivider(s) |
| + case "spacer": |
| + return plan{h: e.spacerHeight(b)} |
| + case "columns": |
| + return e.planColumns(b, s, byID, done) |
| + } |
| + // An unknown type is a block from a newer build of the core. |
| + // Drawing nothing keeps the rest of the slide standing. |
| + return plan{} |
| +} |
| + |
| +// textPlan wraps a block's text and turns it into a plan, reporting a |
| +// line that came out wider than its column — a word longer than the |
| +// box, which is drawn overflowing rather than broken or cut. |
| +func (e *engine) textPlan(t deck.Text, base style, s slot, lead, paraGap float64) plan { |
| + tb := e.wrap(t, base, s.w, lead, paraGap) |
| + e.noteWideLines(tb, s.w) |
| + return plan{ |
| + h: tb.h, w: widest(tb), |
| + paint: func(x, y, w, _ float64) []Op { return e.paint(tb, x, y, w, s.align) }, |
| + } |
| +} |
| + |
| +func (e *engine) noteWideLines(tb *textBlock, w float64) { |
| + for _, ln := range tb.lines { |
| + if ln.overflowed { |
| + e.warn(Warning{Kind: WarnOverflow, Block: e.block, Detail: fmt.Sprintf( |
| + "a word is wider than its %.0fpx column (%.0fpx); it is drawn past the edge rather than broken", |
| + w, ln.w)}) |
| + return |
| + } |
| + } |
| +} |
| + |
| +func widest(tb *textBlock) float64 { |
| + var w float64 |
| + for _, ln := range tb.lines { |
| + w = max(w, ln.w) |
| + } |
| + return w |
| +} |
| + |
| +// ── Text-bearing blocks ────────────────────────────────────────────── |
| + |
| +// planHeading is `h1` to `h3`: the heading face, the top of the type |
| +// scale, and `font-weight: 600`, which with a 400/700 pair on offer is |
| +// the bold face (CSS matches a weight over 500 upwards first). A level |
| +// 3 heading is the accent colour, as the stylesheet has it. |
| +func (e *engine) planHeading(b deck.Block, s slot) plan { |
| + level := int(numProp(b.Props, "level", 1)) |
| + if level < 1 || level > 3 { |
| + level = 1 |
| + } |
| + size := [...]float64{e.sc.t1(), e.sc.t2(), e.sc.t3()}[level-1] |
| + fill := e.fg |
| + if level == 3 { |
| + fill = e.accent |
| + } |
| + st := style{face: e.head, size: size, fill: fill, track: trackHeading} |
| + st.face.Bold = true |
| + // A heading is one line by the schema; a stray second paragraph is |
| + // a line break and not a new paragraph, which is what render does |
| + // with it too (<br>, not a second <h1>), so there is no gap. |
| + return e.textPlan(b.Text, st, s, leadHeading, 0) |
| +} |
| + |
| +func (e *engine) planText(b deck.Block, s slot) plan { |
| + size := e.sc.bodySize(sizeProp(b.Props, "m")) |
| + s.align = alignProp(b.Props, s.align) |
| + st := style{face: e.body, size: size, fill: e.fg} |
| + return e.textPlan(b.Text, st, s, leadBody, paraGapEm*size) |
| +} |
| + |
| +// planList is one item per paragraph (§2.6), each with a marker in the |
| +// accent colour hanging in the padding to the left of the text — which |
| +// is what `list-style-position: outside` does, and why a wrapped item |
| +// lines up under its own first line rather than under the bullet. |
| +func (e *engine) planList(b deck.Block, s slot) plan { |
| + size := e.sc.bodySize(sizeProp(b.Props, "m")) |
| + numbered, _ := b.Props["style"].(string) |
| + indent := int(numProp(b.Props, "indent", 0)) |
| + if indent < 0 { |
| + indent = 0 |
| + } |
| + if indent > 3 { |
| + indent = 3 |
| + } |
| + inset := listIndentEm[indent] * size |
| + st := style{face: e.body, size: size, fill: e.fg} |
| + markerSt := style{face: e.body, size: size, fill: e.accent} |
| + itemGap := listGapEm * size |
| + |
| + type item struct { |
| + tb *textBlock |
| + marker string |
| + markW float64 |
| + } |
| + items := make([]item, 0, len(b.Text)) |
| + var h, w float64 |
| + for i, para := range b.Text { |
| + tb := e.wrap(deck.Text{para}, st, s.w-inset, leadBody, 0) |
| + e.noteWideLines(tb, s.w-inset) |
| + marker := "•" |
| + if numbered == "number" { |
| + marker = strconv.Itoa(i+1) + "." |
| + } |
| + it := item{tb: tb, marker: marker, markW: e.measure(markerSt, marker)} |
| + if i > 0 { |
| + h += itemGap |
| + } |
| + h += tb.h |
| + w = max(w, inset+widest(tb)) |
| + items = append(items, it) |
| + } |
| + |
| + return plan{h: h, w: w, paint: func(x, y, colW, _ float64) []Op { |
| + var ops []Op |
| + top := y |
| + for i, it := range items { |
| + if i > 0 { |
| + top += itemGap |
| + } |
| + ops = append(ops, e.paint(it.tb, x+inset, top, colW-inset, s.align)...) |
| + if len(it.tb.lines) > 0 { |
| + ln := it.tb.lines[0] |
| + baseline := top + (ln.height-(ln.asc+ln.desc))/2 + ln.asc |
| + // The marker sits in the padding, its end a third of an |
| + // em clear of the text. |
| + mx := x + inset - 0.35*size - it.markW |
| + ops = append(ops, Text{X: mx, Y: baseline, Runs: []Run{ |
| + {S: it.marker, Face: markerSt.face, Size: size, Fill: e.accent}, |
| + }}) |
| + } |
| + top += it.tb.h |
| + } |
| + return ops |
| + }} |
| +} |
| + |
| +// planQuote is a pull quote: the words in italic at the largest body |
| +// size, and the attribution under them in the accent colour with the |
| +// em dash the stylesheet's ::before puts there. |
| +func (e *engine) planQuote(b deck.Block, s slot) plan { |
| + size := e.sc.bodyL() |
| + st := style{face: e.body, size: size, fill: e.fg} |
| + st.face.Italic = true |
| + tb := e.wrap(b.Text, st, s.w, leadBody, paraGapEm*size) |
| + e.noteWideLines(tb, s.w) |
| + |
| + var cite *textBlock |
| + citeSize := size * citeEm |
| + if c := stringProp(b.Props, "cite"); c != "" { |
| + citeSt := style{face: e.body, size: citeSize, fill: e.accent} |
| + cite = e.wrap(deck.Text{{{T: "— " + c}}}, citeSt, s.w, leadBody, 0) |
| + } |
| + |
| + h, w := tb.h, widest(tb) |
| + if cite != nil { |
| + h += quoteGap*size + cite.h |
| + w = max(w, widest(cite)) |
| + } |
| + return plan{h: h, w: w, paint: func(x, y, colW, _ float64) []Op { |
| + ops := e.paint(tb, x, y, colW, s.align) |
| + if cite != nil { |
| + ops = append(ops, e.paint(cite, x, y+tb.h+quoteGap*size, colW, s.align)...) |
| + } |
| + return ops |
| + }} |
| +} |
| + |
| +// planCode is a panel with one line of monospace per paragraph (§2.4), |
| +// never wrapped: the stylesheet gives a code block `overflow-x: auto`, |
| +// so on screen a long line scrolls, and breaking one here would put a |
| +// line in the PDF that is not in the deck. |
| +func (e *engine) planCode(b deck.Block, s slot) plan { |
| + size := e.sc.code() |
| + inset := codePadEm * size |
| + st := style{face: e.code, size: size, fill: e.fg} |
| + lineH := leadCode * size |
| + asc, desc, _ := fonts.Metrics(st.face, size) |
| + |
| + lines := make([]string, 0, len(b.Text)) |
| + var widest float64 |
| + for _, para := range b.Text { |
| + // `tab-size: 2`. Measured as two spaces because a tab has no |
| + // glyph and would otherwise be drawn at .notdef's width. |
| + text := strings.ReplaceAll(plainText(para), "\t", " ") |
| + lines = append(lines, text) |
| + widest = max(widest, e.measure(st, text)) |
| + } |
| + if len(lines) == 0 { |
| + lines = append(lines, "") |
| + } |
| + h := 2*inset + float64(len(lines))*lineH |
| + if widest > s.w-2*inset { |
| + e.warn(Warning{Kind: WarnOverflow, Block: b.ID, Detail: fmt.Sprintf( |
| + "a code line is %.0fpx wide in a %.0fpx panel; on screen it scrolls, in print it is drawn past the edge", |
| + widest, s.w-2*inset)}) |
| + } |
| + |
| + panel := mix(e.fg, e.bg, mixCodePanel) |
| + return plan{h: h, w: widest + 2*inset, paint: func(x, y, colW, _ float64) []Op { |
| + ops := []Op{Rect{X: x, Y: y, W: colW, H: h, Fill: panel, Radius: codeRadiusEm * size}} |
| + top := y + inset |
| + for _, text := range lines { |
| + baseline := top + (lineH-(asc+desc))/2 + asc |
| + if text != "" { |
| + ops = append(ops, Text{X: x + inset, Y: baseline, Runs: []Run{ |
| + {S: text, Face: st.face, Size: size, Fill: st.fill}, |
| + }}) |
| + } |
| + top += lineH |
| + } |
| + return ops |
| + }} |
| +} |
| + |
| +// planTable is equal columns, a rule under every row, and a header row |
| +// in bold over an accent rule (§2.4, and the stylesheet's `th`). |
| +func (e *engine) planTable(b deck.Block, s slot) plan { |
| + cells := cellsProp(b.Props) |
| + cols := int(numProp(b.Props, "cols", 0)) |
| + if cols < 1 { |
| + cols = len(cells) |
| + } |
| + if cols < 1 { |
| + return plan{} |
| + } |
| + header := boolProp(b.Props, "header") |
| + size := e.sc.bodyS() |
| + padX, padY := cellPadXEm*size, cellPadYEm*size |
| + colW := s.w / float64(cols) |
| + |
| + base := style{face: e.body, size: size, fill: e.fg} |
| + head := base |
| + head.face.Bold = true |
| + |
| + type row struct { |
| + cells []*textBlock |
| + h float64 |
| + head bool |
| + } |
| + var rows []row |
| + for i := 0; i < len(cells); i += cols { |
| + end := min(i+cols, len(cells)) |
| + r := row{head: header && len(rows) == 0} |
| + st := base |
| + if r.head { |
| + st = head |
| + } |
| + for _, cell := range cells[i:end] { |
| + tb := e.wrap(cell, st, colW-2*padX, leadBody, paraGapEm*size) |
| + r.cells = append(r.cells, tb) |
| + r.h = max(r.h, tb.h) |
| + } |
| + r.h += 2 * padY |
| + rows = append(rows, r) |
| + } |
| + |
| + var h float64 |
| + for _, r := range rows { |
| + h += r.h + cellRule |
| + } |
| + ruleFill := mix(e.fg, e.bg, mixTableRule) |
| + return plan{h: h, w: s.w, paint: func(x, y, colWidth, _ float64) []Op { |
| + var ops []Op |
| + cw := colWidth / float64(cols) |
| + top := y |
| + for _, r := range rows { |
| + for i, cell := range r.cells { |
| + ops = append(ops, e.paint(cell, x+float64(i)*cw+padX, top+padY, cw-2*padX, Start)...) |
| + } |
| + bottom := top + r.h |
| + stroke := ruleFill |
| + if r.head { |
| + stroke = e.accent |
| + } |
| + ops = append(ops, Line{X1: x, Y1: bottom, X2: x + colWidth, Y2: bottom, Stroke: stroke, W: cellRule}) |
| + top = bottom + cellRule |
| + } |
| + return ops |
| + }} |
| +} |
| + |
| +// ── Blocks that are not text ───────────────────────────────────────── |
| + |
| +// planShape draws §2.4's four kinds, mapping render's 0..100 viewBox |
| +// onto the box: the 1-unit inset it draws with becomes one percent of |
| +// the box, and a radius is read the same way (`rx="12"` in that |
| +// viewBox is 12% of the width). |
| +// |
| +// A flow shape starts as tall as it is wide: the stylesheet gives it |
| +// `inline-size: 100%; block-size: auto` over a 1:1 viewBox, and a |
| +// replaced element with an intrinsic ratio and no height is square. |
| +// That is nearly always taller than the region, so it is marked |
| +// flexible and engine.shrink squeezes it down to what is left, no |
| +// further than `min-block-size: 8cqw`. Which is what the browser does |
| +// with it. |
| +// |
| +// One departure from the browser, stated rather than hidden: |
| +// |
| +// - The label is DRAWN, centred. render puts it in the SVG's |
| +// <title>, where a browser announces it and does not paint it; a |
| +// paint list has no accessibility tree to put it in, and dropping |
| +// it would lose the words from the export altogether. |
| +func (e *engine) planShape(b deck.Block, s slot) plan { |
| + kind := stringProp(b.Props, "kind") |
| + switch kind { |
| + case "rect", "ellipse", "line", "arrow": |
| + default: |
| + kind = "rect" |
| + } |
| + fill := e.color(stringProp(b.Props, "fill"), "") |
| + stroke := e.color(stringProp(b.Props, "stroke"), e.fg) |
| + strokeW := clamp(numProp(b.Props, "strokeWidth", 1), 0, 50) |
| + radius := clamp(numProp(b.Props, "radius", 0), 0, 50) |
| + |
| + h := s.h |
| + if h == 0 { |
| + h = max(s.w, shapeMinBlockCqw*e.sc.cqw) |
| + } |
| + |
| + label := b.Text.Plain() |
| + labelSt := style{face: e.body, size: e.sc.bodyM(), fill: e.fg} |
| + var labelBlock *textBlock |
| + if label != "" { |
| + labelBlock = e.wrap(deck.Text{{{T: label}}}, labelSt, s.w, leadBody, 0) |
| + } |
| + |
| + return plan{h: h, w: s.w, flex: s.h == 0, minH: shapeMinBlockCqw * e.sc.cqw, paint: func(x, y, w, h float64) []Op { |
| + insetX, insetY := w*0.01, h*0.01 |
| + var ops []Op |
| + switch kind { |
| + case "ellipse": |
| + ops = append(ops, Ellipse{X: x + insetX, Y: y + insetY, W: w - 2*insetX, H: h - 2*insetY, |
| + Fill: fill, Stroke: stroke, StrokeW: strokeW}) |
| + case "line", "arrow": |
| + ops = append(ops, Line{ |
| + X1: x + insetX, Y1: y + h/2, X2: x + w - insetX, Y2: y + h/2, |
| + Stroke: stroke, W: strokeW, Arrow: kind == "arrow"}) |
| + default: |
| + ops = append(ops, Rect{X: x + insetX, Y: y + insetY, W: w - 2*insetX, H: h - 2*insetY, |
| + Fill: fill, Stroke: stroke, StrokeW: strokeW, Radius: radius / 100 * w}) |
| + } |
| + if labelBlock != nil { |
| + ops = append(ops, e.paint(labelBlock, x, y+(h-labelBlock.h)/2, w, Center)...) |
| + } |
| + return ops |
| + }} |
| +} |
| + |
| +// planImage places a picture. In a region it is as tall as its own |
| +// aspect ratio makes it at the column width; in a canvas box or |
| +// image-full's media region it fills the box, which is what |
| +// `block-size: 100%` does. |
| +// |
| +// An asset the deck never registered draws nothing and is reported. |
| +// render puts an empty frame there for the editor to show as |
| +// unfinished; an export has nobody to show it to. |
| +func (e *engine) planImage(b deck.Block, s slot) plan { |
| + id := stringProp(b.Props, "asset") |
| + asset, ok := e.deck.Assets[id] |
| + if !ok { |
| + e.warn(Warning{Kind: WarnAsset, Block: b.ID, Detail: fmt.Sprintf( |
| + "this image names asset %q, which this deck never registered", id)}) |
| + return plan{} |
| + } |
| + fit := "cover" |
| + if f, _ := b.Props["fit"].(string); f == "contain" { |
| + fit = "contain" |
| + } |
| + alt, _ := b.Props["alt"].(string) |
| + if alt == "" && !boolProp(b.Props, "decorative") { |
| + alt = asset.Alt |
| + } |
| + |
| + h := s.h |
| + if h == 0 { |
| + ratio := imageFallback |
| + if asset.Width > 0 && asset.Height > 0 { |
| + ratio = float64(asset.Height) / float64(asset.Width) |
| + } |
| + h = s.w * ratio |
| + } |
| + return plan{h: h, w: s.w, flex: s.h == 0, paint: func(x, y, w, h float64) []Op { |
| + return []Op{Image{X: x, Y: y, W: w, H: h, Asset: id, Fit: fit, Alt: alt}} |
| + }} |
| +} |
| + |
| +func (e *engine) planDivider(s slot) plan { |
| + fill := mix(e.fg, e.bg, mixDivider) |
| + rule := e.sc.rule() |
| + return plan{h: rule, w: s.w, paint: func(x, y, w, _ float64) []Op { |
| + return []Op{Rect{X: x, Y: y, W: w, H: rule, Fill: fill, Radius: rule / 2}} |
| + }} |
| +} |
| + |
| +// spacerHeight is `[data-block="spacer"]`'s three sizes. It draws |
| +// nothing; it is the gap it makes. |
| +func (e *engine) spacerHeight(b deck.Block) float64 { |
| + switch sizeProp(b.Props, "l") { |
| + case "s": |
| + return e.sc.gap() / 2 |
| + case "l": |
| + return e.sc.gap() * 2 |
| + } |
| + return e.sc.gap() |
| +} |
| + |
| +// planColumns lays a columns block's children out side by side in the |
| +// order props.children names them (§2.4), each in an equal column. |
| +func (e *engine) planColumns(b deck.Block, s slot, byID map[string]deck.Block, done map[string]bool) plan { |
| + count := int(numProp(b.Props, "count", 2)) |
| + if count != 3 { |
| + count = 2 |
| + } |
| + colWidth := (s.w - e.sc.gap()*float64(count-1)) / float64(count) |
| + |
| + var kids []plan |
| + var h float64 |
| + for _, id := range childIDs(b) { |
| + child, ok := byID[id] |
| + if !ok || done[id] { |
| + continue |
| + } |
| + done[id] = true |
| + p := e.plan(child, slot{w: colWidth, h: 0, align: s.align}, byID, done) |
| + kids = append(kids, p) |
| + h = max(h, p.h) |
| + } |
| + if len(kids) == 0 { |
| + return plan{} |
| + } |
| + return plan{h: h, w: s.w, paint: func(x, y, w, _ float64) []Op { |
| + cw := (w - e.sc.gap()*float64(count-1)) / float64(count) |
| + var ops []Op |
| + for i, p := range kids { |
| + if p.paint == nil { |
| + continue |
| + } |
| + ops = append(ops, p.paint(x+float64(i)*(cw+e.sc.gap()), y, cw, p.h)...) |
| + } |
| + return ops |
| + }} |
| +} |
| + |
| +// plainText is one paragraph's runs with the marks dropped — what a |
| +// code block is (§2.4: plain, one paragraph per line). |
| +func plainText(para []deck.Run) string { |
| + var b strings.Builder |
| + for _, r := range para { |
| + b.WriteString(r.T) |
| + } |
| + return b.String() |
| +} |
| + |
| +func clamp(v, lo, hi float64) float64 { |
| + if v < lo { |
| + return lo |
| + } |
| + if v > hi { |
| + return hi |
| + } |
| + return v |
| +} |
diff --git a/internal/slides/layout/fixture_test.go b/internal/slides/layout/fixture_test.go| new file mode 100644 |
| index 0000000..376da59 |
| --- /dev/null |
| +++ b/internal/slides/layout/fixture_test.go |
| @@ -0,0 +1,226 @@ |
| +package layout_test |
| + |
| +import ( |
| + "flag" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| +) |
| + |
| +// update rewrites the golden paint lists instead of comparing against |
| +// them: |
| +// |
| +// go test ./internal/slides/layout -update |
| +// |
| +// Read the diff before committing a regenerated golden. A paint list |
| +// is coordinates, and a coordinate that moved because a font metric |
| +// was read differently looks exactly like one that moved because a |
| +// stylesheet changed — the difference is in the diff and nowhere else. |
| +var update = flag.Bool("update", false, "rewrite the golden paint lists") |
| + |
| +// txt is one paragraph of unmarked text — the shape most fixture blocks |
| +// want. |
| +func txt(s string) deck.Text { return deck.Text{{{T: s}}} } |
| + |
| +// paras is several unmarked paragraphs: list items, code lines, a quote |
| +// that runs on. |
| +func paras(ss ...string) deck.Text { |
| + t := make(deck.Text, 0, len(ss)) |
| + for _, s := range ss { |
| + t = append(t, []deck.Run{{T: s}}) |
| + } |
| + return t |
| +} |
| + |
| +// box is a canvas block's frame, spelled positionally so a fixture |
| +// slide reads as a picture rather than as four labelled fields. |
| +func box(x, y, w, h float64) *deck.Box { return &deck.Box{X: x, Y: y, W: w, H: h} } |
| + |
| +func intp(n int) *int { return &n } |
| + |
| +// fixture is the deck the golden tests lay out: one slide per flow |
| +// template, one canvas slide, one canvas slide set to reflow, and every |
| +// block type in §2.4 somewhere among them. |
| +// |
| +// Hand-built rather than driven through the core: the goldens are about |
| +// what the renderer does with a view, and a literal deck is that view |
| +// with the wasm boundary taken out of the loop. internal/slides/sample.go |
| +// is the same deck said in ops, and this fixture follows its shape. |
| +func fixture() *deck.Deck { |
| + return &deck.Deck{ |
| + Meta: deck.Meta{Title: "Renderer fixture", Theme: "paper", Aspect: "16:9"}, |
| + Assets: map[string]deck.Asset{ |
| + // Refs are what the object store minted: a hex SHA-256, |
| + // abbreviated here so a golden stays readable. What the |
| + // <img> carries is the REF, never the id — the id is the |
| + // deck's own name for the asset and means nothing to the |
| + // route that serves the bytes. |
| + "asset0001aaa": {Ref: "aa01bb02cc03dd04", Name: "chart.png", Width: 1600, Height: 900, Alt: "A bar chart with no bars"}, |
| + "asset0002bbb": {Ref: "ee05ff06aa07bb08", Name: "texture.png", Width: 64, Height: 64}, |
| + }, |
| + Slides: []deck.Slide{ |
| + // 0 — title |
| + { |
| + ID: "slide001aaaa", Layout: "flow", Template: "title", |
| + Background: deck.Background{Kind: "theme"}, |
| + Notes: txt("The title template: one heading and one line under it."), |
| + Blocks: []deck.Block{ |
| + {ID: "b001aaaaaaaa", Type: "heading", Region: "heading", |
| + Props: map[string]any{"level": float64(1)}, Text: txt("Renderer fixture")}, |
| + {ID: "b002aaaaaaaa", Type: "text", Region: "body", |
| + Props: map[string]any{"size": "l"}, Text: txt("One section per slide, one element per block.")}, |
| + }, |
| + }, |
| + // 1 — title-body, with every inline mark and both list styles |
| + { |
| + ID: "slide002aaaa", Layout: "flow", Template: "title-body", |
| + Background: deck.Background{Kind: "theme"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b011aaaaaaaa", Type: "heading", Region: "heading", |
| + Props: map[string]any{"level": float64(1)}, Text: txt("Text and marks")}, |
| + {ID: "b012aaaaaaaa", Type: "text", Region: "body", |
| + Props: map[string]any{"size": "m", "align": "start"}, |
| + Text: deck.Text{{ |
| + {T: "Marks: "}, |
| + {T: "bold", B: true}, |
| + {T: ", "}, |
| + {T: "italic", I: true}, |
| + {T: ", "}, |
| + {T: "code", C: true}, |
| + {T: ", "}, |
| + {T: "highlight", H: true}, |
| + {T: ", "}, |
| + {T: "all four", B: true, I: true, C: true, H: true}, |
| + {T: " and a "}, |
| + {T: "link", Href: "https://amadan.net/oficina/slides"}, |
| + {T: " <not markup>."}, |
| + }}}, |
| + {ID: "b013aaaaaaaa", Type: "list", Region: "body", |
| + Props: map[string]any{"style": "bullet", "size": "m"}, |
| + Text: paras("One item per paragraph", "Bullets or numbers")}, |
| + {ID: "b014aaaaaaaa", Type: "list", Region: "body", |
| + Props: map[string]any{"style": "number", "size": "s", "indent": float64(1)}, |
| + Text: paras("First", "Second")}, |
| + }, |
| + }, |
| + // 2 — two-column, and a columns block whose children must not |
| + // also render at the top level |
| + { |
| + ID: "slide003aaaa", Layout: "flow", Template: "two-column", |
| + Background: deck.Background{Kind: "theme"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b021aaaaaaaa", Type: "heading", Region: "heading", |
| + Props: map[string]any{"level": float64(2)}, Text: txt("Regions, not coordinates")}, |
| + {ID: "b022aaaaaaaa", Type: "text", Region: "body", |
| + Text: txt("A flow slide names regions and blocks stack inside the one they are in.")}, |
| + {ID: "b023aaaaaaaa", Type: "columns", Region: "body", |
| + Props: map[string]any{"count": float64(2), "children": []any{"b025aaaaaaaa", "b024aaaaaaaa"}}}, |
| + {ID: "b024aaaaaaaa", Type: "text", Region: "body", Text: txt("Second column, first in the array.")}, |
| + {ID: "b025aaaaaaaa", Type: "text", Region: "body", Text: txt("First column, second in the array.")}, |
| + {ID: "b026aaaaaaaa", Type: "text", Region: "aside", |
| + Text: txt("The aside is the second column.")}, |
| + }, |
| + }, |
| + // 3 — section, on a slide background the deck set by colour |
| + { |
| + ID: "slide004aaaa", Layout: "flow", Template: "section", |
| + Background: deck.Background{Kind: "color", Value: "#2b1030"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b031aaaaaaaa", Type: "heading", Region: "heading", |
| + Props: map[string]any{"level": float64(1)}, Text: txt("Part two")}, |
| + }, |
| + }, |
| + // 4 — image-full: the alt that comes from the asset |
| + { |
| + ID: "slide005aaaa", Layout: "flow", Template: "image-full", |
| + Background: deck.Background{Kind: "image", Value: "asset0002bbb"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b041aaaaaaaa", Type: "image", Region: "media", |
| + Props: map[string]any{"asset": "asset0001aaa", "fit": "cover", "radius": float64(8)}}, |
| + {ID: "b042aaaaaaaa", Type: "heading", Region: "heading", |
| + Props: map[string]any{"level": float64(2)}, Text: txt("A picture would go here")}, |
| + }, |
| + }, |
| + // 5 — quote, with and without an attribution |
| + { |
| + ID: "slide006aaaa", Layout: "flow", Template: "quote", |
| + Background: deck.Background{Kind: "theme"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b051aaaaaaaa", Type: "quote", Region: "body", |
| + Props: map[string]any{"cite": "The design spec"}, |
| + Text: txt("A deck is a document, not a drawing.")}, |
| + {ID: "b052aaaaaaaa", Type: "quote", Region: "body", |
| + Text: txt("Said by nobody in particular.")}, |
| + }, |
| + }, |
| + // 6 — blank, carrying the block types the other slides do not |
| + { |
| + ID: "slide007aaaa", Layout: "flow", Template: "blank", |
| + Background: deck.Background{Kind: "theme"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b061aaaaaaaa", Type: "code", Region: "body", |
| + Props: map[string]any{"language": "go", "size": "m"}, |
| + Text: paras("func main() {", "\tfmt.Println(\"hi <there>\")", "}")}, |
| + {ID: "b062aaaaaaaa", Type: "table", Region: "body", |
| + Props: map[string]any{ |
| + "rows": float64(2), "cols": float64(2), "header": true, |
| + "cells": []any{ |
| + []any{[]any{map[string]any{"t": "Region"}}}, |
| + []any{[]any{map[string]any{"t": "Holds"}}}, |
| + []any{[]any{map[string]any{"t": "heading"}}}, |
| + []any{[]any{map[string]any{"t": "the title"}}}, |
| + }, |
| + }}, |
| + {ID: "b063aaaaaaaa", Type: "divider", Region: "body"}, |
| + {ID: "b064aaaaaaaa", Type: "spacer", Region: "body", |
| + Props: map[string]any{"size": "l"}}, |
| + {ID: "b065aaaaaaaa", Type: "shape", Region: "body", |
| + Props: map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": float64(2), "radius": float64(12)}, |
| + Text: txt("A labelled rectangle")}, |
| + {ID: "b066aaaaaaaa", Type: "shape", Region: "body", |
| + Props: map[string]any{"kind": "ellipse", "fill": "#cfd9e8", "stroke": "#5a6f8a", "strokeWidth": float64(2)}}, |
| + {ID: "b067aaaaaaaa", Type: "shape", Region: "body", |
| + Props: map[string]any{"kind": "line", "stroke": "#5a6f8a", "strokeWidth": float64(3)}}, |
| + {ID: "b068aaaaaaaa", Type: "shape", Region: "body", |
| + Props: map[string]any{"kind": "arrow", "stroke": "currentColor"}}, |
| + {ID: "b069aaaaaaaa", Type: "image", Region: "body", |
| + Props: map[string]any{"asset": "asset0002bbb", "fit": "contain", "decorative": true, "alt": ""}}, |
| + {ID: "b070aaaaaaaa", Type: "image", Region: "body", |
| + Props: map[string]any{"asset": "asset0002bbb", "fit": "contain"}}, |
| + }, |
| + }, |
| + // 7 — canvas, with a phone override on one block |
| + { |
| + ID: "slide008aaaa", Layout: "canvas", Template: "blank", Phone: "inherit", |
| + Background: deck.Background{Kind: "theme"}, |
| + Blocks: []deck.Block{ |
| + {ID: "b081aaaaaaaa", Type: "heading", Box: box(0.06, 0.08, 0.88, 0.16), Z: 3, |
| + Props: map[string]any{"level": float64(1)}, Text: txt("Canvas: put it where you want it")}, |
| + {ID: "b082aaaaaaaa", Type: "shape", Box: box(0.08, 0.34, 0.36, 0.44), Z: 1, |
| + Props: map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": float64(2), "radius": float64(12)}}, |
| + {ID: "b083aaaaaaaa", Type: "text", Box: box(0.58, 0.36, 0.36, 0.4), Z: 4, |
| + Phone: deck.PhoneOverride{Order: intp(0), Size: "s", Box: box(0.05, 0.5, 0.9, 0.45)}, |
| + Text: txt("Every box is a fraction of the 16:9 frame.")}, |
| + }, |
| + }, |
| + // 8 — canvas with phone: reflow, whose z order is not its |
| + // array order, and one block hidden on phones |
| + { |
| + ID: "slide009aaaa", Layout: "canvas", Template: "blank", Phone: "reflow", Transition: "fade", |
| + Background: deck.Background{Kind: "theme"}, |
| + Notes: txt("The phone layer only ever adds to the desktop layout."), |
| + Blocks: []deck.Block{ |
| + {ID: "b091aaaaaaaa", Type: "heading", Box: box(0.06, 0.1, 0.88, 0.16), Z: 1, |
| + Props: map[string]any{"level": float64(1)}, Text: txt("On a phone this one reflows")}, |
| + {ID: "b092aaaaaaaa", Type: "shape", Box: box(0.64, 0.32, 0.3, 0.44), Z: 2, |
| + Phone: deck.PhoneOverride{Hidden: true}, |
| + Props: map[string]any{"kind": "rect", "fill": "#e0d7e8", "stroke": "#75608a", "strokeWidth": float64(2), "radius": float64(12)}, |
| + Text: txt("Desktop only")}, |
| + {ID: "b093aaaaaaaa", Type: "text", Box: box(0.06, 0.32, 0.52, 0.44), Z: 2, |
| + Text: txt("Setting phone to reflow lays the blocks out as one column.")}, |
| + {ID: "b094aaaaaaaa", Type: "text", Box: box(0.06, 0.8, 0.88, 0.12), Z: 3, |
| + Text: txt("A footnote, last by z.")}, |
| + }, |
| + }, |
| + }, |
| + } |
| +} |
diff --git a/internal/slides/layout/layout.go b/internal/slides/layout/layout.go| new file mode 100644 |
| index 0000000..c7daab7 |
| --- /dev/null |
| +++ b/internal/slides/layout/layout.go |
| @@ -0,0 +1,499 @@ |
| +package layout |
| + |
| +import ( |
| + "fmt" |
| + "slices" |
| + "strings" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| + "amadan.net/oficina/slides/internal/slides/render" |
| +) |
| + |
| +// Layout draws d's slide at index into a paint list. |
| +// |
| +// It fails only on a caller mistake — no deck, or an index that is not |
| +// a slide. Everything a deck can contain lays out as something: an |
| +// unknown theme falls back to the default, an unknown block type draws |
| +// nothing rather than breaking the slide around it, and a prop with |
| +// the wrong type takes its default. A half-written document that |
| +// arrived from a peer mid-edit is the ordinary case, not an error. |
| +// |
| +// The warnings are the things worth saying out loud about the result: |
| +// a block that outgrew its box, a rune no embedded face can draw, an |
| +// image the deck never registered. None of them stops the drawing. |
| +func Layout(d *deck.Deck, index int) (Paint, []Warning, error) { |
| + if d == nil { |
| + return Paint{}, nil, fmt.Errorf("layout: no deck") |
| + } |
| + if index < 0 || index >= len(d.Slides) { |
| + return Paint{}, nil, fmt.Errorf("layout: slide %d is outside a deck of %d", index, len(d.Slides)) |
| + } |
| + e := newEngine(d, index) |
| + e.slide(d.Slides[index]) |
| + return Paint{W: FrameW, H: FrameH, Background: e.bg, Ops: e.ops}, e.warnings, nil |
| +} |
| + |
| +// engine is one Layout call: the palette it paints in, the faces it |
| +// measures with, and what it has drawn and noticed so far. |
| +type engine struct { |
| + deck *deck.Deck |
| + |
| + bg, fg Fill |
| + accent, accent2 Fill |
| + highlight Fill |
| + head, body, code fonts.Face |
| + |
| + // sc is the type and spacing scale this slide is drawn at, which |
| + // depends on whether its section has padding (sizes.go). |
| + sc scale |
| + |
| + ops []Op |
| + warnings []Warning |
| + |
| + // block is the id warnings are attributed to while a block is |
| + // being measured, and seen keeps a missing glyph from being |
| + // reported once per line it appears on. |
| + block string |
| + seen map[rune]bool |
| +} |
| + |
| +func newEngine(d *deck.Deck, index int) *engine { |
| + // The same palette the section carries, overrides and contrast |
| + // judgements included — render.SlideVars is the seam, so the paint |
| + // list cannot form a second opinion about what colour a slide is. |
| + vars, _ := render.SlideVars(d, index) |
| + theme := render.ThemeID(d.Meta.Theme) |
| + |
| + e := &engine{ |
| + deck: d, |
| + bg: Fill(vars["--slides-bg"]), |
| + fg: Fill(vars["--slides-fg"]), |
| + code: fonts.Get(theme, fonts.Code, false, false), |
| + seen: map[rune]bool{}, |
| + } |
| + e.accent = Fill(vars["--slides-accent"]) |
| + e.accent2 = Fill(vars["--slides-accent-2"]) |
| + e.highlight = mix(e.accent, e.bg, mixHighlight) |
| + e.head = e.faceFor(theme, fonts.Heading, vars["--slides-font-heading"]) |
| + e.body = e.faceFor(theme, fonts.Body, vars["--slides-font-body"]) |
| + return e |
| +} |
| + |
| +// faceFor is the face a stack names, or the theme's own when the deck |
| +// named a family this binary does not carry (meta.fonts lets it name |
| +// any at all). The substitution is reported rather than made quietly: |
| +// it is the one case where the browser and this engine are knowingly |
| +// setting a slide in two different faces, and an export nobody warned |
| +// about is an export nobody checks. |
| +func (e *engine) faceFor(theme string, role fonts.Role, stack string) fonts.Face { |
| + if fam, ok := fonts.FamilyForStack(stack); ok { |
| + return fonts.Face{Family: fam} |
| + } |
| + sub := fonts.Get(theme, role, false, false) |
| + e.warn(Warning{Kind: WarnFont, Detail: fmt.Sprintf( |
| + "this deck asks for %q, which is not a face this build carries: measured in %s instead", |
| + strings.TrimSpace(strings.Split(stack, ",")[0]), sub.Family.CSSName())}) |
| + return sub |
| +} |
| + |
| +func (e *engine) warn(w Warning) { e.warnings = append(e.warnings, w) } |
| + |
| +// noteGlyphs reports runes no face could draw, once each per slide. |
| +func (e *engine) noteGlyphs(rs []rune) { |
| + var fresh []string |
| + for _, r := range rs { |
| + if e.seen[r] { |
| + continue |
| + } |
| + e.seen[r] = true |
| + fresh = append(fresh, fmt.Sprintf("%q", r)) |
| + } |
| + if len(fresh) == 0 { |
| + return |
| + } |
| + e.warn(Warning{Kind: WarnGlyph, Block: e.block, Detail: fmt.Sprintf( |
| + "no embedded face has a glyph for %s; drawn at the width of a box", |
| + strings.Join(fresh, ", "))}) |
| +} |
| + |
| +func (e *engine) draw(ops ...Op) { e.ops = append(e.ops, ops...) } |
| + |
| +// ── One slide ──────────────────────────────────────────────────────── |
| + |
| +// slide paints the background and then the blocks, in the order they |
| +// are read: regions in reading order on a flow slide, z order on a |
| +// canvas one. |
| +func (e *engine) slide(s deck.Slide) { |
| + // A picture behind the slide (§2.2) is an image op under |
| + // everything else, covering the frame — `background-size: cover`. |
| + if s.Background.Kind == "image" { |
| + if id := s.Background.Value; id != "" { |
| + if _, ok := e.deck.Assets[id]; ok { |
| + e.draw(Image{X: 0, Y: 0, W: FrameW, H: FrameH, Asset: id, Fit: "cover"}) |
| + } else { |
| + e.warn(Warning{Kind: WarnAsset, Detail: fmt.Sprintf( |
| + "this slide's background names asset %q, which this deck never registered", id)}) |
| + } |
| + } |
| + } |
| + |
| + tmpl := s.Template |
| + if !slices.Contains(Templates(), tmpl) { |
| + tmpl = "blank" |
| + } |
| + e.sc = scaleFor(s.Layout, tmpl) |
| + |
| + // A columns block's children are laid out inside it and nowhere |
| + // else (§2.4) — render.body's rule, and it has to be the same rule |
| + // or a child would be drawn twice. |
| + byID := make(map[string]deck.Block, len(s.Blocks)) |
| + claimed := map[string]bool{} |
| + for _, b := range s.Blocks { |
| + byID[b.ID] = b |
| + } |
| + for _, b := range s.Blocks { |
| + if b.Type != "columns" { |
| + continue |
| + } |
| + for _, id := range childIDs(b) { |
| + if _, ok := byID[id]; ok { |
| + claimed[id] = true |
| + } |
| + } |
| + } |
| + // done is shared across the whole slide, so a block is drawn at |
| + // most once however many columns blocks name it. |
| + done := map[string]bool{} |
| + |
| + if s.Layout == "canvas" { |
| + e.canvas(s, byID, claimed, done) |
| + return |
| + } |
| + |
| + // Flow: group into regions, canonical regions first, then any |
| + // region name the core let through, in first-appearance order. |
| + grouped := map[string][]deck.Block{} |
| + var extra []string |
| + for _, b := range s.Blocks { |
| + if claimed[b.ID] { |
| + continue |
| + } |
| + name := b.Region |
| + switch { |
| + case name == "": |
| + name = "body" |
| + case !slices.Contains(regionOrder, name) && !slices.Contains(extra, name): |
| + extra = append(extra, name) |
| + } |
| + grouped[name] = append(grouped[name], b) |
| + } |
| + // Measure every region, in reading order, then place them, then |
| + // draw them in paint order. The measuring has to come first |
| + // because two of the grids depend on what is in them: a |
| + // `grid-template-rows: auto 1fr` heading row is as tall as the |
| + // heading, and `title` centres its two rows as a pair. |
| + var laid []*region |
| + for _, name := range append(slices.Clone(regionOrder), extra...) { |
| + if len(grouped[name]) == 0 { |
| + continue |
| + } |
| + laid = append(laid, e.measureRegion(tmpl, name, grouped[name], byID, done)) |
| + } |
| + e.place(tmpl, laid) |
| + for _, name := range append(paintOrder(tmpl), extra...) { |
| + for _, rg := range laid { |
| + if rg.name == name { |
| + e.drawRegion(tmpl, rg) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// paintOrder is the order the regions are DRAWN in, which is reading |
| +// order everywhere but one place: image-full puts the heading over the |
| +// picture with a z-index, so the picture has to be painted first. A |
| +// paint list has no z-index, only order, and getting this wrong is not |
| +// subtle — the photograph covers the title. |
| +func paintOrder(tmpl string) []string { |
| + if tmpl == "image-full" { |
| + return []string{"media", "body", "aside", "heading"} |
| + } |
| + return slices.Clone(regionOrder) |
| +} |
| + |
| +// region is one measured region: where the table put it, what is in |
| +// it, how tall that came out, and where the top of it ended up. |
| +type region struct { |
| + name string |
| + box Region |
| + plans []plan |
| + total float64 |
| + y float64 |
| + first string // the id of the first block in it, for a warning |
| +} |
| + |
| +// measure lays one region's blocks out at its width without deciding |
| +// where the top of it goes. |
| +func (e *engine) measureRegion(tmpl, name string, blocks []deck.Block, byID map[string]deck.Block, done map[string]bool) *region { |
| + rg := ®ion{name: name, box: regionFor(tmpl, name), first: firstID(blocks)} |
| + |
| + // image-full's media region is the whole frame and the picture |
| + // fills it (`block-size: 100%`); every other region is as tall as |
| + // what is in it. |
| + fill := 0.0 |
| + if tmpl == "image-full" && name == "media" { |
| + fill = rg.box.H |
| + } |
| + |
| + for _, b := range blocks { |
| + if done[b.ID] { |
| + continue |
| + } |
| + done[b.ID] = true |
| + p := e.plan(b, slot{w: rg.box.W, h: fill, align: rg.box.AlignX}, byID, done) |
| + if p.h == 0 && p.paint == nil { |
| + continue |
| + } |
| + rg.plans = append(rg.plans, p) |
| + } |
| + // The accent bar under a section's heading: slides.css draws it |
| + // with a ::after on the region, so it is the region's and not any |
| + // block's, and it is part of what gets centred with it. |
| + if tmpl == "section" && name == "heading" && len(rg.plans) > 0 { |
| + rg.plans = append(rg.plans, e.sectionRule()) |
| + } |
| + rg.total = e.shrink(rg.plans, rg.box.H) |
| + return rg |
| +} |
| + |
| +// place decides where each region's content starts, and is where the |
| +// two grids that depend on their own contents are handled. |
| +// |
| +// - `title` centres its two auto rows as a PAIR (`align-content: |
| +// center` over `auto auto`), so the heading and the body straddle |
| +// the middle with one gap between them, whatever height each is. |
| +// - `title-body` and `two-column` have `grid-template-rows: auto 1fr`, |
| +// so a heading that runs to two lines pushes the row below it down |
| +// instead of overlapping it. The table's fixed heading row is the |
| +// one-line case; this is every other case. |
| +func (e *engine) place(tmpl string, laid []*region) { |
| + for _, rg := range laid { |
| + rg.y = rg.box.Y |
| + switch rg.box.AlignY { |
| + case Center: |
| + rg.y += (rg.box.H - rg.total) / 2 |
| + case End: |
| + rg.y = rg.box.Bottom() - rg.total |
| + } |
| + } |
| + |
| + byName := map[string]*region{} |
| + for _, rg := range laid { |
| + byName[rg.name] = rg |
| + } |
| + head := byName["heading"] |
| + |
| + switch tmpl { |
| + case "title": |
| + body := byName["body"] |
| + if head == nil || body == nil { |
| + return |
| + } |
| + top := contentY + (contentH-(head.total+sectionGap+body.total))/2 |
| + head.y = top |
| + body.y = top + head.total + sectionGap |
| + case "title-body", "two-column": |
| + if head == nil { |
| + return |
| + } |
| + below := head.y + head.total + sectionGap |
| + for _, name := range []string{"body", "aside"} { |
| + rg := byName[name] |
| + if rg == nil || below <= rg.box.Y { |
| + continue |
| + } |
| + // The row below grew downwards, so it starts lower and has |
| + // less room; a Start-aligned region simply moves. A |
| + // heading long enough to leave it no room at all leaves it |
| + // none — not a negative amount, which would read as |
| + // nonsense in the overflow warning. |
| + rg.box.H = max(0, rg.box.H-(below-rg.box.Y)) |
| + rg.box.Y = below |
| + rg.y = below |
| + } |
| + } |
| +} |
| + |
| +// drawRegion puts one measured region's ops in the paint list. |
| +func (e *engine) drawRegion(tmpl string, rg *region) { |
| + if len(rg.plans) == 0 { |
| + return |
| + } |
| + // image-full's heading sits on a scrim, so the words survive a |
| + // light picture. Drawn from the measured content rather than from |
| + // the region box, because the stylesheet sizes that box to fit |
| + // (`justify-self: start` inside `max-inline-size: 80%`). |
| + if tmpl == "image-full" && rg.name == "heading" { |
| + e.draw(e.scrim(rg.plans, rg.box, rg.y, rg.total)) |
| + } |
| + |
| + top := rg.y |
| + for i, p := range rg.plans { |
| + if i > 0 { |
| + top += e.sc.gap() |
| + } |
| + // A picture or a shape squeezed to nothing draws nothing — |
| + // which is what the browser shows too, and a zero-sized op in |
| + // an export is noise nobody can act on. The overflow warning |
| + // below is what says the region was over-full. |
| + if p.paint != nil && !(p.flex && p.h <= 0) { |
| + e.draw(p.paint(rg.box.X, top, rg.box.W, p.h)...) |
| + } |
| + top += p.h |
| + } |
| + if rg.total > rg.box.H+0.001 { |
| + e.warn(Warning{Kind: WarnOverflow, Block: rg.first, Detail: fmt.Sprintf( |
| + "the %s region of a %s slide holds %.0fpx of content in %.0fpx; it is drawn past the box rather than cut", |
| + rg.name, tmpl, rg.total, rg.box.H)}) |
| + } |
| +} |
| + |
| +// shrink is the one piece of flexbox this engine implements, and it is |
| +// here because leaving it out is visible from across a room. |
| +// |
| +// A region is a column flex container with a definite height. Text |
| +// blocks cannot shrink — their minimum content size is the wrapped |
| +// text — but a picture and a shape can, and they do: an image block in |
| +// a body region is `display: flex` over an `<img>` at 100%, so its |
| +// hypothetical size is the picture's own aspect ratio at the column |
| +// width (1680 tall for a square picture in a 1680 column), and the |
| +// browser shrinks it to whatever the region has left. Without this a |
| +// slide with one heading and one photograph puts the photograph three |
| +// slides below the bottom of the frame. |
| +// |
| +// The CSS algorithm with equal shrink factors: distribute the excess |
| +// in proportion to each flexible item's base size, freeze anything |
| +// that hits its floor, and go round again. It returns the height the |
| +// region's contents ended up as, gaps included. |
| +func (e *engine) shrink(plans []plan, avail float64) float64 { |
| + total := e.sc.gap() * float64(len(plans)-1) |
| + var flexible []int |
| + for i, p := range plans { |
| + total += p.h |
| + if p.flex { |
| + flexible = append(flexible, i) |
| + } |
| + } |
| + for total > avail+0.001 && len(flexible) > 0 { |
| + excess := total - avail |
| + var base float64 |
| + for _, i := range flexible { |
| + base += plans[i].h |
| + } |
| + if base <= 0 { |
| + break |
| + } |
| + var frozen []int |
| + var shrunk float64 |
| + for _, i := range flexible { |
| + want := plans[i].h - excess*plans[i].h/base |
| + if want <= plans[i].minH { |
| + want = plans[i].minH |
| + frozen = append(frozen, i) |
| + } |
| + shrunk += plans[i].h - want |
| + plans[i].h = want |
| + } |
| + total -= shrunk |
| + if len(frozen) == 0 { |
| + break |
| + } |
| + // Drop the frozen ones and, if the excess is not used up, |
| + // take it out of the rest. |
| + var rest []int |
| + for _, i := range flexible { |
| + if !slices.Contains(frozen, i) { |
| + rest = append(rest, i) |
| + } |
| + } |
| + flexible = rest |
| + } |
| + return total |
| +} |
| + |
| +// scrim is the panel behind image-full's heading: the region's own |
| +// background, its padding, and its corner radius. |
| +// |
| +// The stylesheet mixes it 82% towards transparent over the picture. |
| +// This draws it opaque, because a paint list has no compositing — an |
| +// SVG and a PDF would blend it differently, and the scrim exists to |
| +// make words readable over an unknown photograph, which an opaque |
| +// panel does and a wrongly-composited one might not. |
| +func (e *engine) scrim(plans []plan, r Region, y, total float64) Rect { |
| + w := 0.0 |
| + for _, p := range plans { |
| + w = max(w, p.w) |
| + } |
| + w = min(w+2*scrimPadX, scrimW) |
| + return Rect{ |
| + X: r.X - scrimPadX, Y: y - scrimPadY, |
| + W: w, H: total + 2*scrimPadY, |
| + Fill: e.bg, Radius: 1 * e.sc.cqw, |
| + } |
| +} |
| + |
| +// sectionRule is the accent bar under a section slide's heading — |
| +// `[data-template="section"] [data-region="heading"]::after`, 18cqw of |
| +// the accent colour, and the only decoration in the whole stylesheet. |
| +func (e *engine) sectionRule() plan { |
| + w := 18 * e.sc.cqw |
| + rule := e.sc.rule() |
| + return plan{h: rule, w: w, paint: func(x, y, colW, _ float64) []Op { |
| + return []Op{Rect{X: x + (colW-w)/2, Y: y, W: w, H: rule, Fill: e.accent, Radius: rule / 2}} |
| + }} |
| +} |
| + |
| +// canvas draws blocks where they were put (§2.3): each box is a |
| +// fraction of the frame, and z is paint order. |
| +func (e *engine) canvas(s deck.Slide, byID map[string]deck.Block, claimed, done map[string]bool) { |
| + blocks := slices.Clone(s.Blocks) |
| + // Stable, so two blocks at the same z keep the order the array |
| + // had — the same tie-break render's reflow order takes. |
| + slices.SortStableFunc(blocks, func(a, b deck.Block) int { return a.Z - b.Z }) |
| + |
| + for _, b := range blocks { |
| + if claimed[b.ID] || done[b.ID] { |
| + continue |
| + } |
| + done[b.ID] = true |
| + box := canvasBox(b.Box) |
| + p := e.plan(b, slot{w: box.W, h: box.H, align: Start}, byID, done) |
| + if p.paint != nil { |
| + e.draw(p.paint(box.X, box.Y, box.W, p.h)...) |
| + } |
| + if p.h > box.H { |
| + e.warn(Warning{Kind: WarnOverflow, Block: b.ID, Detail: fmt.Sprintf( |
| + "a %s block holds %.0fpx of content in a %.0fpx box; it is drawn past the box rather than cut", |
| + b.Type, p.h, box.H)}) |
| + } |
| + } |
| +} |
| + |
| +// canvasBox turns §2.3's fractions into frame coordinates. A block |
| +// with no box at all — which the core does not produce and a peer |
| +// mid-edit might — gets the content box, which is where a browser's |
| +// static position would put it. |
| +func canvasBox(b *deck.Box) Box { |
| + if b == nil { |
| + return Box{contentX, contentY, contentW, contentH} |
| + } |
| + return Box{X: b.X * FrameW, Y: b.Y * FrameH, W: b.W * FrameW, H: b.H * FrameH} |
| +} |
| + |
| +func firstID(blocks []deck.Block) string { |
| + if len(blocks) == 0 { |
| + return "" |
| + } |
| + return blocks[0].ID |
| +} |
diff --git a/internal/slides/layout/layout_test.go b/internal/slides/layout/layout_test.go| new file mode 100644 |
| index 0000000..e19036c |
| --- /dev/null |
| +++ b/internal/slides/layout/layout_test.go |
| @@ -0,0 +1,576 @@ |
| +package layout_test |
| + |
| +import ( |
| + "encoding/json" |
| + "math" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| + "amadan.net/oficina/slides/internal/slides/layout" |
| +) |
| + |
| +// goldenNames pairs a fixture slide with the paint list it writes. |
| +// Between them the nine cover every flow template, both layout modes, |
| +// and every block type in §2.4. |
| +var goldenNames = []struct { |
| + name string |
| + index int |
| +}{ |
| + {"title", 0}, |
| + {"title-body", 1}, |
| + {"two-column", 2}, |
| + {"section", 3}, |
| + {"image-full", 4}, |
| + {"quote", 5}, |
| + {"blank", 6}, |
| + {"canvas", 7}, |
| + {"canvas-reflow", 8}, |
| +} |
| + |
| +func paintOf(t *testing.T, d *deck.Deck, i int) (layout.Paint, []layout.Warning) { |
| + t.Helper() |
| + p, warnings, err := layout.Layout(d, i) |
| + if err != nil { |
| + t.Fatalf("Layout(%d): %v", i, err) |
| + } |
| + return p, warnings |
| +} |
| + |
| +func TestPaintGoldens(t *testing.T) { |
| + d := fixture() |
| + for _, g := range goldenNames { |
| + t.Run(g.name, func(t *testing.T) { |
| + p, _ := paintOf(t, d, g.index) |
| + got, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + got = append(got, '\n') |
| + |
| + path := filepath.Join("testdata", g.name+".golden.json") |
| + if *update { |
| + if err := os.WriteFile(path, got, 0o644); err != nil { |
| + t.Fatalf("write golden: %v", err) |
| + } |
| + return |
| + } |
| + want, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read golden (run with -update to create it): %v", err) |
| + } |
| + if string(got) != string(want) { |
| + t.Errorf("%s does not match the golden; run with -update and read the diff", g.name) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Every golden also has to be a paint list somebody could actually |
| +// draw: no NaN, no infinity, nothing with a negative size. A NaN |
| +// coordinate serialises as `null` in JSON and draws as nothing at all, |
| +// so it would pass a golden comparison forever without anybody seeing |
| +// the slide it wrecked. |
| +func TestNoOpIsNaNOrNegativelySized(t *testing.T) { |
| + d := fixture() |
| + for _, g := range goldenNames { |
| + p, _ := paintOf(t, d, g.index) |
| + finite := func(name string, v float64) { |
| + t.Helper() |
| + if math.IsNaN(v) || math.IsInf(v, 0) { |
| + t.Errorf("%s: %s is %v", g.name, name, v) |
| + } |
| + } |
| + size := func(name string, v float64) { |
| + t.Helper() |
| + finite(name, v) |
| + if v < 0 { |
| + t.Errorf("%s: %s is negative (%v)", g.name, name, v) |
| + } |
| + } |
| + for _, op := range p.Ops { |
| + switch o := op.(type) { |
| + case layout.Rect: |
| + finite("rect x", o.X) |
| + finite("rect y", o.Y) |
| + size("rect w", o.W) |
| + size("rect h", o.H) |
| + size("rect radius", o.Radius) |
| + size("rect stroke width", o.StrokeW) |
| + case layout.Ellipse: |
| + finite("ellipse x", o.X) |
| + finite("ellipse y", o.Y) |
| + size("ellipse w", o.W) |
| + size("ellipse h", o.H) |
| + case layout.Line: |
| + finite("line x1", o.X1) |
| + finite("line y1", o.Y1) |
| + finite("line x2", o.X2) |
| + finite("line y2", o.Y2) |
| + size("line width", o.W) |
| + case layout.Image: |
| + finite("image x", o.X) |
| + finite("image y", o.Y) |
| + size("image w", o.W) |
| + size("image h", o.H) |
| + case layout.Text: |
| + finite("text x", o.X) |
| + finite("text baseline", o.Y) |
| + if len(o.Runs) == 0 { |
| + t.Errorf("%s: a text op has no runs", g.name) |
| + } |
| + for _, r := range o.Runs { |
| + size("run size", r.Size) |
| + if r.S == "" { |
| + t.Errorf("%s: a run has no text", g.name) |
| + } |
| + if r.Fill == "" { |
| + t.Errorf("%s: a run has no colour", g.name) |
| + } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestTheFrameIsAlways1920By1080(t *testing.T) { |
| + d := fixture() |
| + for _, g := range goldenNames { |
| + p, _ := paintOf(t, d, g.index) |
| + if p.W != 1920 || p.H != 1080 { |
| + t.Errorf("%s is %vx%v", g.name, p.W, p.H) |
| + } |
| + } |
| +} |
| + |
| +func TestLayoutRejectsAnIndexOutsideTheDeck(t *testing.T) { |
| + d := fixture() |
| + for _, i := range []int{-1, len(d.Slides), len(d.Slides) + 10} { |
| + if _, _, err := layout.Layout(d, i); err == nil { |
| + t.Errorf("Layout(%d) of a %d-slide deck returned no error", i, len(d.Slides)) |
| + } |
| + } |
| + if _, _, err := layout.Layout(nil, 0); err == nil { |
| + t.Error("Layout(nil) returned no error") |
| + } |
| +} |
| + |
| +// The background is the theme's, or the slide's own colour when it set |
| +// one — the same value render writes as --slides-bg, because both come |
| +// from render.SlideVars. |
| +func TestTheBackgroundIsTheSlidesOwnColour(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 0) |
| + if p.Background != "#faf7f0" { |
| + t.Errorf("a paper slide's background is %q, want #faf7f0", p.Background) |
| + } |
| + p, _ = paintOf(t, d, 3) // the section slide sets its own plum |
| + if p.Background != "#2b1030" { |
| + t.Errorf("a slide with its own background is %q, want #2b1030", p.Background) |
| + } |
| +} |
| + |
| +// A slide with a picture behind it paints that first, under |
| +// everything: an op list is drawn in order. |
| +func TestASlideBackgroundImageIsPaintedFirst(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 4) |
| + if len(p.Ops) == 0 { |
| + t.Fatal("the image-full slide painted nothing") |
| + } |
| + img, ok := p.Ops[0].(layout.Image) |
| + if !ok { |
| + t.Fatalf("the first op is %T, want the background image", p.Ops[0]) |
| + } |
| + if img.Asset != "asset0002bbb" || img.W != 1920 || img.H != 1080 { |
| + t.Errorf("the background image is %+v", img) |
| + } |
| +} |
| + |
| +// §2.3: a canvas box is a fraction of the frame, verbatim. This is the |
| +// one place the engine is not allowed any judgement at all — somebody |
| +// put the block there. |
| +func TestCanvasBoxesArePlacedVerbatim(t *testing.T) { |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper"}, |
| + Slides: []deck.Slide{{ |
| + ID: "s", Layout: "canvas", Template: "blank", |
| + Blocks: []deck.Block{{ |
| + ID: "b", Type: "shape", Z: 1, Box: box(0.25, 0.5, 0.5, 0.25), |
| + Props: map[string]any{"kind": "rect", "fill": "#ff0000", "stroke": "none"}, |
| + }}, |
| + }}, |
| + } |
| + p, _ := paintOf(t, d, 0) |
| + var got layout.Rect |
| + for _, op := range p.Ops { |
| + if r, ok := op.(layout.Rect); ok { |
| + got = r |
| + } |
| + } |
| + // The 1% inset is render's own viewBox geometry (`x="1"` in a |
| + // 0..100 box), not a margin this package invented. |
| + wantX, wantW := 0.25*1920+0.5*1920*0.01, 0.5*1920*0.98 |
| + if math.Abs(got.X-wantX) > 0.01 || math.Abs(got.W-wantW) > 0.01 { |
| + t.Errorf("a box at x 0.25 w 0.5 painted at x %v w %v, want %v and %v", got.X, got.W, wantX, wantW) |
| + } |
| + if math.Abs(got.Y-(0.5*1080+0.25*1080*0.01)) > 0.01 { |
| + t.Errorf("a box at y 0.5 painted at y %v", got.Y) |
| + } |
| +} |
| + |
| +// z is paint order (§2.3), and a tie keeps the array's order. |
| +func TestCanvasPaintsInZOrder(t *testing.T) { |
| + mk := func(id string, z int) deck.Block { |
| + return deck.Block{ID: id, Type: "text", Z: z, Box: box(0, 0, 1, 0.2), Text: txt(id)} |
| + } |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper"}, |
| + Slides: []deck.Slide{{ |
| + ID: "s", Layout: "canvas", Template: "blank", |
| + Blocks: []deck.Block{mk("third", 9), mk("first", 1), mk("second", 4), mk("tie", 9)}, |
| + }}, |
| + } |
| + p, _ := paintOf(t, d, 0) |
| + var got []string |
| + for _, op := range p.Ops { |
| + if txt, ok := op.(layout.Text); ok { |
| + got = append(got, txt.Runs[0].S) |
| + } |
| + } |
| + want := []string{"first", "second", "third", "tie"} |
| + if strings.Join(got, ",") != strings.Join(want, ",") { |
| + t.Errorf("painted %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// §2.5: the print engine never reads the phone layer. A block hidden |
| +// on a phone is still on the printed page, and one the phone reorders |
| +// is still where the desktop put it. |
| +func TestThePhoneLayerIsNeverRead(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 8) // the reflow slide, whose shape is phone-hidden |
| + var found bool |
| + for _, op := range p.Ops { |
| + if txt, ok := op.(layout.Text); ok && strings.Contains(txt.Runs[0].S, "Desktop only") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Error("a block hidden on phones was left out of the paint list") |
| + } |
| +} |
| + |
| +// The overflow rule, stated in the plan and in the stylesheet's own |
| +// canvas comment: never clip. A word wider than its column is drawn |
| +// past the edge and reported. |
| +func TestALongWordOverflowsRatherThanBeingCut(t *testing.T) { |
| + const long = "Unconscionablyunhyphenatedsupercalifragilisticexpialidocioussequence" |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper"}, |
| + Slides: []deck.Slide{{ |
| + ID: "s", Layout: "flow", Template: "two-column", |
| + Blocks: []deck.Block{{ID: "b1", Type: "text", Region: "body", Text: txt(long)}}, |
| + }}, |
| + } |
| + p, warnings := paintOf(t, d, 0) |
| + |
| + var drawn string |
| + var right float64 |
| + for _, op := range p.Ops { |
| + if txt, ok := op.(layout.Text); ok { |
| + for _, r := range txt.Runs { |
| + drawn += r.S |
| + w, _ := fonts.Advance(r.Face, r.Size, r.S) |
| + right = math.Max(right, txt.X+w) |
| + } |
| + } |
| + } |
| + if drawn != long { |
| + t.Errorf("the word was drawn as %q, want it whole", drawn) |
| + } |
| + col := 999.936 // where two-column's body region ends |
| + if right <= col { |
| + t.Errorf("the word ends at %v, inside the %v column: it should overflow, not be cut", right, col) |
| + } |
| + if !hasWarning(warnings, layout.WarnOverflow, "b1") { |
| + t.Errorf("no overflow warning; got %+v", warnings) |
| + } |
| +} |
| + |
| +// A block that runs past the bottom of its region is drawn and |
| +// reported too — the CSS lets it overflow (slides.css's canvas rules |
| +// say why), so this does. |
| +func TestABlockTallerThanItsRegionIsReported(t *testing.T) { |
| + lines := make(deck.Text, 40) |
| + for i := range lines { |
| + lines[i] = []deck.Run{{T: "Another line of a body that will not fit on one slide."}} |
| + } |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper"}, |
| + Slides: []deck.Slide{{ |
| + ID: "s", Layout: "flow", Template: "blank", |
| + Blocks: []deck.Block{{ID: "tall", Type: "text", Region: "body", Text: lines}}, |
| + }}, |
| + } |
| + p, warnings := paintOf(t, d, 0) |
| + if len(p.Ops) < 40 { |
| + t.Errorf("only %d ops: the overflowing lines were dropped rather than drawn", len(p.Ops)) |
| + } |
| + if !hasWarning(warnings, layout.WarnOverflow, "tall") { |
| + t.Errorf("no overflow warning; got %+v", warnings) |
| + } |
| +} |
| + |
| +// A rune no embedded face can draw is reported once, and the text |
| +// around it is still laid out. |
| +func TestARuneNoFaceCanDrawIsReported(t *testing.T) { |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper"}, |
| + Slides: []deck.Slide{{ |
| + ID: "s", Layout: "flow", Template: "blank", |
| + Blocks: []deck.Block{{ID: "b1", Type: "text", Region: "body", |
| + Text: deck.Text{{{T: "emoji 😀 and 😀 again"}}}}}, |
| + }}, |
| + } |
| + _, warnings := paintOf(t, d, 0) |
| + var glyph int |
| + for _, w := range warnings { |
| + if w.Kind == layout.WarnGlyph { |
| + glyph++ |
| + if !strings.Contains(w.Detail, "😀") { |
| + t.Errorf("the warning does not name the rune: %q", w.Detail) |
| + } |
| + } |
| + } |
| + if glyph != 1 { |
| + t.Errorf("got %d glyph warnings, want exactly one for a rune that appears twice", glyph) |
| + } |
| +} |
| + |
| +// An image whose asset the deck never registered draws nothing and |
| +// says so, rather than leaving a hole nobody can explain. |
| +func TestAnUnregisteredImageIsReported(t *testing.T) { |
| + d := &deck.Deck{ |
| + Meta: deck.Meta{Theme: "paper"}, |
| + Slides: []deck.Slide{{ |
| + ID: "s", Layout: "flow", Template: "blank", |
| + Blocks: []deck.Block{{ID: "b1", Type: "image", Region: "body", |
| + Props: map[string]any{"asset": "nothing"}}}, |
| + }}, |
| + } |
| + p, warnings := paintOf(t, d, 0) |
| + for _, op := range p.Ops { |
| + if _, ok := op.(layout.Image); ok { |
| + t.Error("an image with no asset was drawn anyway") |
| + } |
| + } |
| + if !hasWarning(warnings, layout.WarnAsset, "b1") { |
| + t.Errorf("no asset warning; got %+v", warnings) |
| + } |
| +} |
| + |
| +// meta.fonts can name any family at all (§2.1), and one this build |
| +// does not carry is measured in a substitute — which the browser will |
| +// not be using, so it is reported. |
| +func TestADeckFontThisBuildLacksIsReported(t *testing.T) { |
| + d := fixture() |
| + d.Meta.Fonts = &deck.Fonts{Heading: "Comic Sans MS, cursive", Body: "Comic Sans MS, cursive"} |
| + _, warnings := paintOf(t, d, 0) |
| + var font int |
| + for _, w := range warnings { |
| + if w.Kind == layout.WarnFont { |
| + font++ |
| + } |
| + } |
| + if font != 2 { |
| + t.Errorf("got %d font warnings for a heading and a body face, want 2: %+v", font, warnings) |
| + } |
| +} |
| + |
| +// The six themes all lay out, and paper is the one that measures its |
| +// headings in a serif — which is the fact fonts.Get holds and the |
| +// stylesheet's --slides-font-heading agrees with. |
| +func TestEveryThemeLaysOutAndPaperHeadsInSerif(t *testing.T) { |
| + for _, theme := range []string{"paper", "ink", "sunset", "forest", "slate", "candy", "no-such-theme"} { |
| + d := fixture() |
| + d.Meta.Theme = theme |
| + p, _ := paintOf(t, d, 0) |
| + |
| + var heading layout.Run |
| + for _, op := range p.Ops { |
| + if txt, ok := op.(layout.Text); ok && txt.Runs[0].Size > 100 { |
| + heading = txt.Runs[0] |
| + break |
| + } |
| + } |
| + if heading.S == "" { |
| + t.Errorf("%s: no heading was painted", theme) |
| + continue |
| + } |
| + want := fonts.Sans |
| + if theme == "paper" || theme == "no-such-theme" { // an unknown theme is paper |
| + want = fonts.Serif |
| + } |
| + if heading.Face.Family != want { |
| + t.Errorf("%s heads in %s, want %s", theme, heading.Face.Family.CSSName(), want.CSSName()) |
| + } |
| + if !heading.Face.Bold { |
| + t.Errorf("%s: a heading is font-weight 600, which is the bold face", theme) |
| + } |
| + } |
| +} |
| + |
| +// Marks change the face, not the line: §2.6's five marks and a link, |
| +// all on one line of one block. |
| +func TestMarksBecomeFacesColoursAndAnUnderline(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 1) // the marks slide |
| + faces := map[string]bool{} |
| + var underlines int |
| + var marks int |
| + for _, op := range p.Ops { |
| + switch o := op.(type) { |
| + case layout.Text: |
| + for _, r := range o.Runs { |
| + faces[r.Face.PostScriptName()] = true |
| + } |
| + case layout.Line: |
| + underlines++ |
| + case layout.Rect: |
| + marks++ |
| + } |
| + } |
| + for _, want := range []string{ |
| + "LiberationSans-Regular", "LiberationSans-Bold", "LiberationSans-Italic", |
| + "LiberationMono-Regular", "LiberationMono-BoldItalic", "LiberationSerif-Bold", |
| + } { |
| + if !faces[want] { |
| + t.Errorf("no run was set in %s; got %v", want, keys(faces)) |
| + } |
| + } |
| + if underlines == 0 { |
| + t.Error("the link has no underline: §8 does not let colour alone carry it") |
| + } |
| + if marks == 0 { |
| + t.Error("the highlighted run has no mark behind it") |
| + } |
| +} |
| + |
| +// A list draws a marker per item, in the accent colour, hanging to the |
| +// left of the text. |
| +func TestAListDrawsItsMarkers(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 1) |
| + markers := map[string]float64{} |
| + textX := map[string]float64{} |
| + for _, op := range p.Ops { |
| + txt, ok := op.(layout.Text) |
| + if !ok { |
| + continue |
| + } |
| + switch s := txt.Runs[0].S; { |
| + case s == "•" || s == "1." || s == "2.": |
| + markers[s] = txt.X |
| + case strings.HasPrefix(s, "One item"), strings.HasPrefix(s, "First"): |
| + textX[s] = txt.X |
| + } |
| + } |
| + for _, want := range []string{"•", "1.", "2."} { |
| + if _, ok := markers[want]; !ok { |
| + t.Errorf("no %q marker was drawn", want) |
| + } |
| + } |
| + for s, x := range textX { |
| + for m, mx := range markers { |
| + if mx >= x && strings.HasPrefix(s, "One item") && m == "•" { |
| + t.Errorf("the bullet at %v is not left of its text at %v", mx, x) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// A table is equal columns with a rule under every row, the header's |
| +// in the accent colour. |
| +func TestATableIsEqualColumnsWithRules(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 6) |
| + var rules []layout.Line |
| + for _, op := range p.Ops { |
| + if l, ok := op.(layout.Line); ok { |
| + rules = append(rules, l) |
| + } |
| + } |
| + if len(rules) < 2 { |
| + t.Fatalf("a two-row table drew %d rules", len(rules)) |
| + } |
| + if rules[0].Stroke != "#8a5410" { |
| + t.Errorf("the header rule is %q, want the accent", rules[0].Stroke) |
| + } |
| + if rules[0].W != 1 { |
| + t.Errorf("a table rule is %vpx, want 1", rules[0].W) |
| + } |
| +} |
| + |
| +// The section template's accent bar is part of what gets centred, and |
| +// it is the only decoration the stylesheet has. |
| +func TestASectionSlideDrawsItsAccentBar(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 3) |
| + var bar *layout.Rect |
| + for _, op := range p.Ops { |
| + if r, ok := op.(layout.Rect); ok && r.H < 20 && r.W > 300 && r.W < 400 { |
| + bar = &r |
| + } |
| + } |
| + if bar == nil { |
| + t.Fatal("no accent bar on a section slide") |
| + } |
| + if bar.Fill == "" || bar.Fill == p.Background { |
| + t.Errorf("the accent bar is %q on a %q background", bar.Fill, p.Background) |
| + } |
| + if mid := bar.X + bar.W/2; math.Abs(mid-960) > 1 { |
| + t.Errorf("the accent bar is centred on %v, want 960", mid) |
| + } |
| +} |
| + |
| +// Nothing on a title slide starts outside its region, and the pair of |
| +// regions straddles the middle — which is what `align-content: center` |
| +// over two auto rows does. |
| +func TestATitleSlideIsCentred(t *testing.T) { |
| + d := fixture() |
| + p, _ := paintOf(t, d, 0) |
| + var top, bottom float64 = math.Inf(1), math.Inf(-1) |
| + for _, op := range p.Ops { |
| + if txt, ok := op.(layout.Text); ok { |
| + top = math.Min(top, txt.Y) |
| + bottom = math.Max(bottom, txt.Y) |
| + } |
| + } |
| + if math.IsInf(top, 1) { |
| + t.Fatal("the title slide painted no text") |
| + } |
| + if mid := (top + bottom) / 2; mid < 400 || mid > 680 { |
| + t.Errorf("the title slide's text is centred on %v, which is not near the middle of 1080", mid) |
| + } |
| +} |
| + |
| +func hasWarning(ws []layout.Warning, kind, block string) bool { |
| + for _, w := range ws { |
| + if w.Kind == kind && w.Block == block { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func keys(m map[string]bool) []string { |
| + out := make([]string, 0, len(m)) |
| + for k := range m { |
| + out = append(out, k) |
| + } |
| + return out |
| +} |
diff --git a/internal/slides/layout/paint.go b/internal/slides/layout/paint.go| new file mode 100644 |
| index 0000000..7e8066d |
| --- /dev/null |
| +++ b/internal/slides/layout/paint.go |
| @@ -0,0 +1,326 @@ |
| +// Package layout is the print half of the one rule (spec §0.1): the |
| +// same block tree internal/slides/render turns into HTML, measured and |
| +// placed into a fixed 1920 by 1080 frame. |
| +// |
| +// What comes out is a paint list — a background and a flat, ordered |
| +// sequence of rectangles, ellipses, lines, images and positioned runs |
| +// of text. It is the ONLY input to the SVG serialiser and to the PDF |
| +// writer (spec §4.2, and the slice 3 plan's global constraints say it |
| +// out loud): nothing downstream of here reads a deck. That is what |
| +// keeps a thumbnail and a printed page from being two opinions about |
| +// one slide. |
| +// |
| +// # Where the numbers come from |
| +// |
| +// internal/slides/static/slides.css, evaluated at 1920 by 1080 — |
| +// sizes.go for the scale, regions.go for each template's grid. Both |
| +// are written as the arithmetic they are so a stylesheet change can be |
| +// followed here by reading it, and both were CHECKED against a real |
| +// Chromium rather than reasoned about: internal/slidestest's |
| +// TestTheStylesheetMeasuresWhatTheLayoutEngineAssumes is that check, |
| +// and it found two mistakes the Go tests could not have (a container |
| +// query unit measures the content box, and a picture in a region is a |
| +// flex item that shrinks). |
| +// |
| +// # What this does not claim |
| +// |
| +// Pixel parity with the browser. It is close — every region box and |
| +// type size above agrees with Chromium to within a pixel — and these |
| +// are the places it is not: |
| +// |
| +// - Line breaking, which spec §4.2 names as the expected divergence. |
| +// Both sides measure with the same font programs at the same |
| +// sizes, but this one does not kern (internal/slides/fonts reads |
| +// no GPOS), so a measured string is about a percent narrower in |
| +// the browser and a line can break one word earlier here. |
| +// - The space BETWEEN regions. `[slides-slide] { gap }` is a `cqw` |
| +// length written on the query container itself, so the browser |
| +// resolves it against whatever is outside the section — the gap |
| +// between a heading and a body moves with the browser window. |
| +// This engine uses the value a slide filling a 1920 screen gets, |
| +// which is the projector and the export; a slide in a small window |
| +// is spaced differently on screen. That is slides.css's to fix. |
| +// - A shape's label is drawn here and is not on screen: render puts |
| +// it in the SVG's <title>, where it is announced and not painted, |
| +// and a paint list has no accessibility tree to put it in. |
| +// - image-full's heading scrim is opaque, where the stylesheet mixes |
| +// it 82% towards transparent (layout.go says why), and the four |
| +// greys the stylesheet builds with color-mix() in oklab are mixed |
| +// in plain sRGB (props.go says why). |
| +// |
| +// Text is never clipped. A block whose content outgrows its box draws |
| +// past it and records a Warning, exactly as the stylesheet lets it |
| +// overflow rather than cutting a sentence in half (slides.css's canvas |
| +// rules say why: losing the end of a line to `overflow: hidden` is a |
| +// WCAG 1.4.10 failure nobody ever reports). |
| +// |
| +// The phone layer is never read (§2.5: the print engine does not). |
| +package layout |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "math" |
| + |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| +) |
| + |
| +// Fill is a colour, as `#rrggbb`, or "" for none. |
| +// |
| +// A string rather than a struct because every one of these came from |
| +// the theme (render.SlideVars) or from a prop render already |
| +// allowlisted, and every consumer — SVG, PDF — wants it back as text. |
| +// "" is the absence of a colour, which is a real value: a shape with |
| +// no fill is an outline. |
| +type Fill string |
| + |
| +// Paint is one slide, drawn. |
| +// |
| +// W and H are always the 1920 by 1080 frame (§9: other aspect ratios |
| +// are not built). Ops are in paint order — first drawn is furthest |
| +// back — which for a canvas slide is z order and for a flow slide is |
| +// the order the regions and their blocks are read in. |
| +type Paint struct { |
| + W, H float64 |
| + Background Fill |
| + Ops []Op |
| +} |
| + |
| +// Op is one thing to draw. The interface is closed on purpose: the SVG |
| +// serialiser and the PDF writer both switch over these five, and a |
| +// sixth added without their knowing would be silently skipped by one |
| +// of them. Adding one means adding it there too, which is what an |
| +// unexported method makes unavoidable. |
| +type Op interface { |
| + // kind is the name this op is written under in a paint list. |
| + kind() string |
| +} |
| + |
| +// Rect is a rectangle: a code block's panel, a table rule, a |
| +// highlight, a shape. |
| +type Rect struct { |
| + X, Y, W, H float64 |
| + Fill Fill |
| + Stroke Fill |
| + StrokeW float64 |
| + Radius float64 |
| +} |
| + |
| +// Ellipse is an ellipse inscribed in the box X, Y, W, H — the box, not |
| +// a centre and two radii, because every caller here has a box. |
| +type Ellipse struct { |
| + X, Y, W, H float64 |
| + Fill Fill |
| + Stroke Fill |
| + StrokeW float64 |
| +} |
| + |
| +// Line is a straight line. Arrow puts a solid head at the second end, |
| +// which is the `arrow` shape and the only decoration this vocabulary |
| +// has. |
| +type Line struct { |
| + X1, Y1, X2, Y2 float64 |
| + Stroke Fill |
| + W float64 |
| + Arrow bool |
| +} |
| + |
| +// Image is a picture to place in a box. Asset is the deck's own asset |
| +// id (§2.7) and NOT a URL or a ref: what a consumer does with it |
| +// differs — a thumbnail inlines the bytes as a data URI, a PDF embeds |
| +// them, an SVG for the editor points at a route — and each of those |
| +// needs the deck to resolve it. Fit is `cover` or `contain`, Alt is |
| +// what §2.4 requires of every image. |
| +type Image struct { |
| + X, Y, W, H float64 |
| + Asset string |
| + Fit string |
| + Alt string |
| +} |
| + |
| +// Text is one line of text at a baseline. X is where the line starts |
| +// and Y is the baseline — not the top — because that is what both a |
| +// PDF text object and an SVG <text> take, and because runs of |
| +// different sizes on one line share a baseline and share nothing else. |
| +// |
| +// One op per line. The line breaking already happened (text.go); a |
| +// consumer draws the runs left to right, advancing by each run's own |
| +// measured width, and never re-wraps. |
| +type Text struct { |
| + X, Y float64 |
| + Runs []Run |
| +} |
| + |
| +// Run is a stretch of text in one face at one size in one colour. |
| +// Href is the link a run carries (§2.6's `href` mark), for a consumer |
| +// that can make one; the underline a link is drawn with is a separate |
| +// Line op, because §8 does not let colour alone carry state and a PDF |
| +// annotation would not put a mark on the page. |
| +type Run struct { |
| + S string |
| + Face fonts.Face |
| + Size float64 |
| + Fill Fill |
| + Href string |
| +} |
| + |
| +func (Rect) kind() string { return "rect" } |
| +func (Ellipse) kind() string { return "ellipse" } |
| +func (Line) kind() string { return "line" } |
| +func (Image) kind() string { return "image" } |
| +func (Text) kind() string { return "text" } |
| + |
| +// ── Warnings ───────────────────────────────────────────────────────── |
| + |
| +// Warning is something the caller should be able to say out loud about |
| +// an export: this block ran past its box, this deck asked for a |
| +// character these faces cannot draw. §11's degrade-loudly rule — the |
| +// slide is still drawn, and the reason it looks wrong is reported |
| +// rather than left for somebody to discover in a printed handout. |
| +type Warning struct { |
| + Kind string `json:"kind"` |
| + Block string `json:"block,omitempty"` |
| + Detail string `json:"detail"` |
| +} |
| + |
| +const ( |
| + // WarnOverflow is content drawn past the box it was given. |
| + WarnOverflow = "overflow" |
| + // WarnGlyph is a rune no embedded face has a glyph for. It is |
| + // drawn at .notdef's width, which is what a browser shows a box |
| + // at. |
| + WarnGlyph = "glyph" |
| + // WarnAsset is an image block whose asset this deck never |
| + // registered, or registered with a ref nothing can serve. |
| + WarnAsset = "asset" |
| + // WarnFont is a face this deck asked for that this build does not |
| + // carry (meta.fonts, §2.1) — measured in a substitute, which the |
| + // browser will not be using. |
| + WarnFont = "font" |
| +) |
| + |
| +// ── The paint list as JSON ─────────────────────────────────────────── |
| +// |
| +// Only ever written, never read back: it is what the golden files in |
| +// testdata are, and a paint list is produced from a deck rather than |
| +// loaded. So there is no UnmarshalJSON here, and the shapes below are |
| +// chosen for a person reading a diff — an op says its own name first, |
| +// and a face is its PostScript name rather than three fields. |
| + |
| +func (p Paint) MarshalJSON() ([]byte, error) { |
| + type paint struct { |
| + W float64 `json:"w"` |
| + H float64 `json:"h"` |
| + Background Fill `json:"background"` |
| + Ops []Op `json:"ops"` |
| + } |
| + return json.Marshal(paint{p.W, p.H, p.Background, p.Ops}) |
| +} |
| + |
| +// r4 rounds a coordinate to four decimals for the paint list's JSON. |
| +// |
| +// The arithmetic here runs through ratios of a 19.2px unit, so a |
| +// coordinate that is 126.72 comes out of the floats as |
| +// 126.72000000000001 about as often as not. Nothing downstream can see |
| +// a ten-thousandth of a pixel, and a golden file that changes because |
| +// a sum was reassociated is a golden file nobody reads. |
| +func r4(v float64) float64 { return math.Round(v*1e4) / 1e4 } |
| + |
| +func (r Rect) MarshalJSON() ([]byte, error) { |
| + type alias struct { |
| + X float64 `json:"x"` |
| + Y float64 `json:"y"` |
| + W float64 `json:"w"` |
| + H float64 `json:"h"` |
| + Fill Fill `json:"fill,omitempty"` |
| + Stroke Fill `json:"stroke,omitempty"` |
| + StrokeW float64 `json:"strokeWidth,omitempty"` |
| + Radius float64 `json:"radius,omitempty"` |
| + } |
| + return opJSON(r.kind(), alias{r4(r.X), r4(r.Y), r4(r.W), r4(r.H), r.Fill, r.Stroke, r4(r.StrokeW), r4(r.Radius)}) |
| +} |
| + |
| +func (e Ellipse) MarshalJSON() ([]byte, error) { |
| + type alias struct { |
| + X float64 `json:"x"` |
| + Y float64 `json:"y"` |
| + W float64 `json:"w"` |
| + H float64 `json:"h"` |
| + Fill Fill `json:"fill,omitempty"` |
| + Stroke Fill `json:"stroke,omitempty"` |
| + StrokeW float64 `json:"strokeWidth,omitempty"` |
| + } |
| + return opJSON(e.kind(), alias{r4(e.X), r4(e.Y), r4(e.W), r4(e.H), e.Fill, e.Stroke, r4(e.StrokeW)}) |
| +} |
| + |
| +func (l Line) MarshalJSON() ([]byte, error) { |
| + type alias struct { |
| + X1 float64 `json:"x1"` |
| + Y1 float64 `json:"y1"` |
| + X2 float64 `json:"x2"` |
| + Y2 float64 `json:"y2"` |
| + Stroke Fill `json:"stroke,omitempty"` |
| + W float64 `json:"width"` |
| + Arrow bool `json:"arrow,omitempty"` |
| + } |
| + return opJSON(l.kind(), alias{r4(l.X1), r4(l.Y1), r4(l.X2), r4(l.Y2), l.Stroke, r4(l.W), l.Arrow}) |
| +} |
| + |
| +func (i Image) MarshalJSON() ([]byte, error) { |
| + type alias struct { |
| + X float64 `json:"x"` |
| + Y float64 `json:"y"` |
| + W float64 `json:"w"` |
| + H float64 `json:"h"` |
| + Asset string `json:"asset"` |
| + Fit string `json:"fit"` |
| + Alt string `json:"alt"` |
| + } |
| + return opJSON(i.kind(), alias{r4(i.X), r4(i.Y), r4(i.W), r4(i.H), i.Asset, i.Fit, i.Alt}) |
| +} |
| + |
| +func (t Text) MarshalJSON() ([]byte, error) { |
| + type alias struct { |
| + X float64 `json:"x"` |
| + Y float64 `json:"y"` |
| + Runs []Run `json:"runs"` |
| + } |
| + return opJSON(t.kind(), alias{r4(t.X), r4(t.Y), t.Runs}) |
| +} |
| + |
| +// MarshalJSON writes the face by name — "LiberationSans-Bold" — which |
| +// is what a person reading a golden file wants to see, and what the |
| +// PDF registers the face under anyway. |
| +func (r Run) MarshalJSON() ([]byte, error) { |
| + type alias struct { |
| + S string `json:"s"` |
| + Face string `json:"face"` |
| + Size float64 `json:"size"` |
| + Fill Fill `json:"fill,omitempty"` |
| + Href string `json:"href,omitempty"` |
| + } |
| + return json.Marshal(alias{r.S, r.Face.PostScriptName(), r4(r.Size), r.Fill, r.Href}) |
| +} |
| + |
| +// opJSON marshals v and splices the op's name in as the first key, so |
| +// a paint list reads as a list of named things rather than of |
| +// coordinates that have to be guessed at. |
| +func opJSON(kind string, v any) ([]byte, error) { |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + return nil, err |
| + } |
| + name, err := json.Marshal(kind) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var buf bytes.Buffer |
| + buf.WriteString(`{"op":`) |
| + buf.Write(name) |
| + if len(b) > 2 { |
| + buf.WriteByte(',') |
| + buf.Write(b[1 : len(b)-1]) |
| + } |
| + buf.WriteByte('}') |
| + return buf.Bytes(), nil |
| +} |
diff --git a/internal/slides/layout/props.go b/internal/slides/layout/props.go| new file mode 100644 |
| index 0000000..b45acf5 |
| --- /dev/null |
| +++ b/internal/slides/layout/props.go |
| @@ -0,0 +1,217 @@ |
| +package layout |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| +) |
| + |
| +// props.go reads a block's free-form props, and turns the colours a |
| +// deck may name into the `#rrggbb` a paint list carries. |
| +// |
| +// Every read takes a default and every enumerated value is checked |
| +// against its vocabulary, for the reason render's own props section |
| +// gives: props arrive from a peer mid-edit and can hold anything at |
| +// all. A prop of the wrong type takes its default rather than |
| +// travelling any further. |
| + |
| +func stringProp(props map[string]any, key string) string { |
| + s, _ := props[key].(string) |
| + return s |
| +} |
| + |
| +func numProp(props map[string]any, key string, def float64) float64 { |
| + switch v := props[key].(type) { |
| + case float64: |
| + return v |
| + case int: |
| + return float64(v) |
| + case json.Number: |
| + if f, err := v.Float64(); err == nil { |
| + return f |
| + } |
| + } |
| + return def |
| +} |
| + |
| +func boolProp(props map[string]any, key string) bool { |
| + b, _ := props[key].(bool) |
| + return b |
| +} |
| + |
| +// sizeProp is §2.4's s, m, l — the step, not the size. |
| +func sizeProp(props map[string]any, def string) string { |
| + switch s, _ := props["size"].(string); s { |
| + case "s", "m", "l": |
| + return s |
| + } |
| + return def |
| +} |
| + |
| +// alignProp is a text block's own alignment, or the region's when it |
| +// has none. `justify` lays out as `start` here: justification changes |
| +// where the words sit on a line and not where the line breaks, so it |
| +// is a serialiser's job (SVG has no justified text at all) and this |
| +// package refusing to guess is better than it guessing differently |
| +// from the browser. |
| +func alignProp(props map[string]any, def Align) Align { |
| + switch a, _ := props["align"].(string); a { |
| + case "center": |
| + return Center |
| + case "end": |
| + return End |
| + case "start", "justify": |
| + return Start |
| + } |
| + return def |
| +} |
| + |
| +// childIDs is a columns block's props.children (§2.4). |
| +func childIDs(b deck.Block) []string { |
| + switch v := b.Props["children"].(type) { |
| + case []string: |
| + return v |
| + case []any: |
| + out := make([]string, 0, len(v)) |
| + for _, c := range v { |
| + if s, ok := c.(string); ok { |
| + out = append(out, s) |
| + } |
| + } |
| + return out |
| + } |
| + return nil |
| +} |
| + |
| +// cellsProp reads a table's props.cells: a row-major array of texts |
| +// (core/src/view.rs). Back through JSON rather than walked by hand, |
| +// for render's reason — the view built it with encoding/json's shapes, |
| +// and a round trip is shorter and harder to get wrong than five type |
| +// assertions deep. |
| +func cellsProp(props map[string]any) []deck.Text { |
| + raw, ok := props["cells"] |
| + if !ok { |
| + return nil |
| + } |
| + b, err := json.Marshal(raw) |
| + if err != nil { |
| + return nil |
| + } |
| + var out []deck.Text |
| + if err := json.Unmarshal(b, &out); err != nil { |
| + return nil |
| + } |
| + return out |
| +} |
| + |
| +// ── Colours ────────────────────────────────────────────────────────── |
| + |
| +// basicColors is CSS's sixteen original keywords, which is what a |
| +// person typing a colour by hand into a shape actually types. A |
| +// keyword outside it resolves to the fallback the caller names rather |
| +// than travelling into a PDF, where "rebeccapurple" is not a colour |
| +// anything can draw. |
| +var basicColors = map[string]Fill{ |
| + "black": "#000000", "silver": "#c0c0c0", "gray": "#808080", "grey": "#808080", |
| + "white": "#ffffff", "maroon": "#800000", "red": "#ff0000", "purple": "#800080", |
| + "fuchsia": "#ff00ff", "green": "#008000", "lime": "#00ff00", "olive": "#808000", |
| + "yellow": "#ffff00", "navy": "#000080", "blue": "#0000ff", "teal": "#008080", |
| + "aqua": "#00ffff", |
| +} |
| + |
| +// color resolves a colour a deck named into one this package can |
| +// paint: `#rgb` and `#rrggbb` verbatim, `currentColor` as the slide's |
| +// foreground, `none` and `transparent` as no colour at all, one of the |
| +// basic keywords as its hex, and anything else as def. |
| +// |
| +// An alpha channel is dropped, with the colour kept: a paint list has |
| +// no opacity (the SVG and the PDF would disagree about compositing it) |
| +// and dropping the channel loses less than dropping the colour. |
| +func (e *engine) color(v string, def Fill) Fill { |
| + v = strings.TrimSpace(v) |
| + switch strings.ToLower(v) { |
| + case "": |
| + return def |
| + case "currentcolor": |
| + return e.fg |
| + case "none", "transparent": |
| + return "" |
| + } |
| + if strings.HasPrefix(v, "#") { |
| + if hex, ok := normalizeHex(v); ok { |
| + return hex |
| + } |
| + return def |
| + } |
| + if c, ok := basicColors[strings.ToLower(v)]; ok { |
| + return c |
| + } |
| + return def |
| +} |
| + |
| +// normalizeHex widens #rgb to #rrggbb, lowercases, and drops an alpha |
| +// channel. |
| +func normalizeHex(v string) (Fill, bool) { |
| + h := strings.ToLower(v[1:]) |
| + switch len(h) { |
| + case 4: // #rgba |
| + h = h[:3] |
| + case 8: // #rrggbbaa |
| + h = h[:6] |
| + } |
| + if len(h) == 3 { |
| + h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) |
| + } |
| + if len(h) != 6 { |
| + return "", false |
| + } |
| + for _, c := range h { |
| + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { |
| + return "", false |
| + } |
| + } |
| + return Fill("#" + h), true |
| +} |
| + |
| +// mix is the stylesheet's color-mix(in oklab, A p%, B), done in plain |
| +// sRGB. |
| +// |
| +// It is an approximation and the only one in this package that is not |
| +// arithmetic: oklab mixing is perceptual and this is not, so a panel |
| +// mixed here is a shade or two off the browser's. It is used for the |
| +// four greys the stylesheet builds that way — a code panel, a table |
| +// rule, a divider, a highlight — where being a shade off is invisible |
| +// and being absent is not. Getting it exact means an oklab conversion |
| +// nothing else here needs. |
| +func mix(a, b Fill, frac float64) Fill { |
| + ca, ok1 := parseHex(a) |
| + cb, ok2 := parseHex(b) |
| + if !ok1 || !ok2 { |
| + return b |
| + } |
| + var out [3]int |
| + for i := range out { |
| + v := float64(ca[i])*frac + float64(cb[i])*(1-frac) |
| + out[i] = int(v + 0.5) |
| + } |
| + return Fill(fmt.Sprintf("#%02x%02x%02x", out[0], out[1], out[2])) |
| +} |
| + |
| +func parseHex(f Fill) ([3]int, bool) { |
| + s := string(f) |
| + if len(s) != 7 || s[0] != '#' { |
| + return [3]int{}, false |
| + } |
| + var out [3]int |
| + for i := range out { |
| + v, err := strconv.ParseUint(s[1+i*2:3+i*2], 16, 8) |
| + if err != nil { |
| + return [3]int{}, false |
| + } |
| + out[i] = int(v) |
| + } |
| + return out, true |
| +} |
diff --git a/internal/slides/layout/regions.go b/internal/slides/layout/regions.go| new file mode 100644 |
| index 0000000..69f71a7 |
| --- /dev/null |
| +++ b/internal/slides/layout/regions.go |
| @@ -0,0 +1,179 @@ |
| +package layout |
| + |
| +// regions.go is where each flow template puts its regions, at 1920 by |
| +// 1080, and it is the ONE place that is written down (the slice 3 plan |
| +// says so). Everything else in this package asks for a region by name. |
| +// |
| +// It is a reading of internal/slides/static/slides.css's |
| +// `grid-template-areas`: the stylesheet is the truth, this is that |
| +// truth evaluated at one size. regions_test.go parses the stylesheet |
| +// and holds the two to the same set of region names per template — a |
| +// structural guard, which is the honest one to make there — and |
| +// internal/slidestest's browser drive checks the boxes themselves |
| +// against a real Chromium at 1920. |
| +// |
| +// A table cannot express a grid's `auto` row, which is as tall as |
| +// whatever ends up in it, so the numbers here are the one-line case |
| +// and engine.place adjusts two of them once the content has been |
| +// measured: `title` centres its two rows as a pair, and a heading that |
| +// runs to two lines pushes `title-body`'s and `two-column`'s lower row |
| +// down instead of overlapping it. This table is where they start. |
| + |
| +// Align is where a region's content sits inside the region's box: the |
| +// stack of blocks in the block direction, and each line of text in the |
| +// inline direction. |
| +type Align uint8 |
| + |
| +const ( |
| + // Start is the top, or the reading-order start of a line. |
| + Start Align = iota |
| + // Center is `align-content: center` / `text-align: center`. |
| + Center |
| + // End is the bottom, or the end of a line. |
| + End |
| +) |
| + |
| +// Box is a rectangle in frame coordinates. Not the Rect op — that one |
| +// is something to draw; this one is somewhere to draw in. |
| +type Box struct{ X, Y, W, H float64 } |
| + |
| +// Bottom and Right are the two edges the flow code keeps asking for. |
| +func (b Box) Bottom() float64 { return b.Y + b.H } |
| +func (b Box) Right() float64 { return b.X + b.W } |
| + |
| +// Region is one grid area: where it is, and how what goes in it sits. |
| +type Region struct { |
| + Box |
| + // AlignY is how the stack of blocks sits in the box, and AlignX is |
| + // how each line sits in its own width. A block with its own |
| + // `align` prop overrides AlignX for that block alone (§2.4). |
| + AlignY, AlignX Align |
| +} |
| + |
| +// The content box: the frame less the section's own padding. |
| +const ( |
| + contentX = pad |
| + contentY = pad |
| + contentW = FrameW - 2*pad |
| + contentH = FrameH - 2*pad |
| +) |
| + |
| +// The derived measurements each template's grid needs. The type sizes |
| +// are the padded scale, because every template that has a heading row |
| +// keeps the section's padding; image-full's are the full one, which is |
| +// what `padding: 0` gives it (sizes.go says why there are two). |
| +const ( |
| + // headingRow is what a template's `grid-template-rows: auto 1fr` |
| + // gives its heading: one line of the biggest type. A heading that |
| + // runs to two lines overflows into the body region and says so — |
| + // which is what the browser does not do (its `auto` row grows), |
| + // and is the largest single divergence in this table. |
| + headingRow = 6.6 * cqwPadded * leadHeading |
| + |
| + // titleRow is half the content box, for `title`'s two auto rows. |
| + titleRow = (contentH - sectionGap) / 2 |
| + // titleW is the `max-inline-size: 80%` those rows are held to. |
| + // The browser shrink-wraps the region to its own content and |
| + // centres that; centring the same lines inside the full 80% puts |
| + // them in the same place, and wraps at the same width. |
| + titleW = 0.80 * contentW |
| + titleX = contentX + (contentW-titleW)/2 |
| + |
| + // two-column's `column-gap`, which is written on the container — |
| + // see sizes.go's sectionColGap. |
| + colW = (contentW - sectionColGap) / 2 |
| + |
| + // quote's `max-inline-size: 84%`. |
| + quoteW = 0.84 * contentW |
| + quoteX = contentX + (contentW-quoteW)/2 |
| + |
| + // image-full's heading sits over the picture's bottom-left corner: |
| + // `margin: var(--slides-pad)`, `padding: calc(gap/2) gap`, |
| + // `max-inline-size: 80%`. scrimW is the outer cap; the box below |
| + // is the text inside the padding. |
| + scrimW = 0.80 * FrameW |
| + scrimPadX = 2.6 * cqwFull |
| + scrimPadY = scrimPadX / 2 |
| + scrimRow = 5.0 * cqwFull * leadHeading |
| +) |
| + |
| +// regions is the table. A template is a set of named areas; a region |
| +// name a template has no area for is not in the map, and regionFor |
| +// falls back the way the stylesheet does (auto placement into the one |
| +// column every template has). |
| +var regions = map[string]map[string]Region{ |
| + // title — everything centred, in the middle. Two auto rows under |
| + // `align-content: center`, so the heading hangs from the bottom of |
| + // the top half and the body sits at the top of the lower one. |
| + "title": { |
| + "heading": {Box{titleX, contentY, titleW, titleRow}, End, Center}, |
| + "body": {Box{titleX, contentY + titleRow + sectionGap, titleW, titleRow}, Start, Center}, |
| + }, |
| + // title-body — the workhorse: `grid-template-rows: auto 1fr`. |
| + "title-body": { |
| + "heading": {Box{contentX, contentY, contentW, headingRow}, Start, Start}, |
| + "body": {Box{contentX, contentY + headingRow + sectionGap, contentW, contentH - headingRow - sectionGap}, Start, Start}, |
| + }, |
| + // two-column — the heading spans, body and aside sit side by side. |
| + "two-column": { |
| + "heading": {Box{contentX, contentY, contentW, headingRow}, Start, Start}, |
| + "body": {Box{contentX, contentY + headingRow + sectionGap, colW, contentH - headingRow - sectionGap}, Start, Start}, |
| + "aside": {Box{contentX + colW + sectionColGap, contentY + headingRow + sectionGap, colW, contentH - headingRow - sectionGap}, Start, Start}, |
| + }, |
| + // section — one heading, centred, over the accent bar drawn by the |
| + // region's ::after (the only decoration in the whole stylesheet, |
| + // and blocks.go draws it). |
| + "section": { |
| + "heading": {Box{contentX, contentY, contentW, contentH}, Center, Center}, |
| + }, |
| + // image-full — the media fills the frame (`padding: 0`), and the |
| + // heading is placed into the same area over it. |
| + "image-full": { |
| + "media": {Box{0, 0, FrameW, FrameH}, Start, Start}, |
| + "heading": {Box{contentX + scrimPadX, FrameH - pad - scrimPadY - scrimRow, scrimW - 2*scrimPadX, scrimRow}, End, Start}, |
| + }, |
| + // quote — one pull quote, centred, nothing competing with it. |
| + "quote": { |
| + "body": {Box{quoteX, contentY, quoteW, contentH}, Center, Center}, |
| + }, |
| + // blank — a single body region, stacked from the top. It is also |
| + // what a canvas slide reflowed onto a phone becomes, though print |
| + // never sees that (§2.5). |
| + "blank": { |
| + "body": {Box{contentX, contentY, contentW, contentH}, Start, Start}, |
| + }, |
| +} |
| + |
| +// regionOrder is the order regions are drawn in, matching |
| +// render.regionOrder — the reading order a screen reader hears, and |
| +// therefore the order a paint list stacks in. |
| +var regionOrder = []string{"heading", "body", "aside", "media"} |
| + |
| +// regionFor is where a template puts a named region. |
| +// |
| +// A name the template has no area for falls back to its body, or to |
| +// the whole content box: the stylesheet auto-places such a region into |
| +// the single column every template's grid has, so it lands where the |
| +// body would. An aside on a title slide is unusual, not an error |
| +// (render's own comment: it is what keeps it readable). |
| +func regionFor(template, name string) Region { |
| + byName, ok := regions[template] |
| + if !ok { |
| + byName = regions["blank"] |
| + } |
| + if r, ok := byName[name]; ok { |
| + return r |
| + } |
| + if r, ok := byName["body"]; ok { |
| + return r |
| + } |
| + return Region{Box{contentX, contentY, contentW, contentH}, Start, Start} |
| +} |
| + |
| +// Templates is the flow templates this table covers, in the order |
| +// slides.css declares them. It matches render's own vocabulary; a |
| +// template outside it renders as blank there and lays out as blank |
| +// here. |
| +func Templates() []string { |
| + return []string{"title", "title-body", "two-column", "section", "image-full", "quote", "blank"} |
| +} |
diff --git a/internal/slides/layout/regions_test.go b/internal/slides/layout/regions_test.go| new file mode 100644 |
| index 0000000..d35fbc0 |
| --- /dev/null |
| +++ b/internal/slides/layout/regions_test.go |
| @@ -0,0 +1,210 @@ |
| +package layout |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "slices" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// regions_test.go is the structural guard the slice 3 plan asks for: |
| +// the region table is a reading of internal/slides/static/slides.css, |
| +// so the stylesheet is parsed here and the two are held to the same |
| +// set of region names per template. |
| +// |
| +// Names, not pixels. A grid's `auto` row is as tall as what is in it |
| +// and this table has to name a number before anything is measured, so |
| +// there is no coordinate here the stylesheet could be checked against. |
| +// What CAN drift silently is a template gaining or losing a region — |
| +// somebody adds an `aside` to `title` in CSS and the engine keeps |
| +// putting it where the body goes, on screen and never in print. That |
| +// is what this catches. |
| + |
| +func stylesheet(t *testing.T) string { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join("..", "static", "slides.css")) |
| + if err != nil { |
| + t.Fatalf("read slides.css: %v", err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// areasFor reads every region name a template places, two ways: the |
| +// areas named in its `grid-template-areas`, and any region given an |
| +// explicit `grid-area` in a rule scoped to that template (which is how |
| +// image-full puts its heading over the picture). |
| +func areasFor(t *testing.T, css, template string) []string { |
| + t.Helper() |
| + var out []string |
| + add := func(name string) { |
| + if name != "" && name != "." && !slices.Contains(out, name) { |
| + out = append(out, name) |
| + } |
| + } |
| + |
| + sel := regexp.QuoteMeta(`[slides-slide][data-template="` + template + `"]`) |
| + // The template's own rule, and its `grid-template-areas`. |
| + own := regexp.MustCompile(sel + `\s*\{([^}]*)\}`) |
| + for _, m := range own.FindAllStringSubmatch(css, -1) { |
| + areas := regexp.MustCompile(`grid-template-areas:\s*([^;]*);`).FindStringSubmatch(m[1]) |
| + if areas == nil { |
| + continue |
| + } |
| + for _, row := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(areas[1], -1) { |
| + for _, name := range strings.Fields(row[1]) { |
| + add(name) |
| + } |
| + } |
| + } |
| + // A region placed into an area by a rule of its own. |
| + scoped := regexp.MustCompile(sel + `\s*\[data-region="([a-z-]+)"\]\s*\{([^}]*)\}`) |
| + for _, m := range scoped.FindAllStringSubmatch(css, -1) { |
| + if strings.Contains(m[2], "grid-area:") { |
| + add(m[1]) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func TestEveryTemplateInTheStylesheetIsInTheTable(t *testing.T) { |
| + css := stylesheet(t) |
| + for _, tmpl := range Templates() { |
| + if !strings.Contains(css, `[data-template="`+tmpl+`"]`) { |
| + t.Errorf("slides.css has no rules for the %q template", tmpl) |
| + } |
| + if _, ok := regions[tmpl]; !ok { |
| + t.Errorf("regions has no entry for the %q template", tmpl) |
| + } |
| + } |
| + for tmpl := range regions { |
| + if !slices.Contains(Templates(), tmpl) { |
| + t.Errorf("regions has an entry for %q, which is not a template", tmpl) |
| + } |
| + } |
| +} |
| + |
| +// The set of region names per template, both ways round. |
| +func TestRegionNamesMatchTheStylesheetGrid(t *testing.T) { |
| + css := stylesheet(t) |
| + for _, tmpl := range Templates() { |
| + want := areasFor(t, css, tmpl) |
| + if len(want) == 0 { |
| + t.Errorf("no grid areas found for %q in slides.css", tmpl) |
| + continue |
| + } |
| + got := regions[tmpl] |
| + for _, name := range want { |
| + if _, ok := got[name]; !ok { |
| + t.Errorf("slides.css puts a %q region on a %s slide; the table has no box for it", |
| + name, tmpl) |
| + } |
| + } |
| + for name := range got { |
| + if !slices.Contains(want, name) { |
| + t.Errorf("the table gives a %s slide a %q region; slides.css places no such area", |
| + tmpl, name) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Every area name a template uses is also a region name the renderer |
| +// writes: `[slides-region][data-region="X"] { grid-area: X }` is the |
| +// identity mapping, and this table leans on that. |
| +func TestAreaNamesAreRegionNames(t *testing.T) { |
| + css := stylesheet(t) |
| + for _, name := range regionOrder { |
| + rule := regexp.MustCompile(regexp.QuoteMeta(`[slides-region][data-region="`+name+`"]`) + |
| + `\s*\{\s*grid-area:\s*` + name + `;`) |
| + if !rule.MatchString(css) { |
| + t.Errorf("slides.css does not map the %q region to the %q area", name, name) |
| + } |
| + } |
| +} |
| + |
| +// Every box lands inside the frame and is the right way up. A region |
| +// with a negative width is a table entry somebody typed wrong, and it |
| +// would show up as a slide with nothing on it rather than as an error. |
| +func TestEveryRegionBoxIsInsideTheFrame(t *testing.T) { |
| + for tmpl, byName := range regions { |
| + for name, r := range byName { |
| + switch { |
| + case r.W <= 0 || r.H <= 0: |
| + t.Errorf("%s/%s is %vx%v", tmpl, name, r.W, r.H) |
| + case r.X < 0 || r.Y < 0: |
| + t.Errorf("%s/%s starts at %v,%v", tmpl, name, r.X, r.Y) |
| + case r.Right() > FrameW+0.001 || r.Bottom() > FrameH+0.001: |
| + t.Errorf("%s/%s ends at %v,%v, outside the frame", tmpl, name, r.Right(), r.Bottom()) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The numbers the whole package is built on, spelled out once so a |
| +// change to the stylesheet's scale fails here first. |
| +// |
| +// The wanted values are Chromium's, read off a real slide at 1920 (the |
| +// browser drive's probe, slice 3 task 2): a padded template's h1 is |
| +// 110.88px and a canvas slide's is 126.72, because a container query |
| +// unit is a percent of the container's CONTENT box and the section's |
| +// padding is 120 each side. |
| +func TestTheScaleIsTheStylesheetAt1920(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + got float64 |
| + want float64 |
| + }{ |
| + {"1cqw with the section's padding", padded.cqw, 16.8}, |
| + {"1cqw without it", full.cqw, 19.2}, |
| + {"--slides-t-1 padded", padded.t1(), 110.88}, |
| + {"--slides-t-1 full", full.t1(), 126.72}, |
| + {"--slides-t-2 padded", padded.t2(), 84}, |
| + {"--slides-t-3 padded", padded.t3(), 67.2}, |
| + {"--slides-body-l padded", padded.bodyL(), 57.12}, |
| + {"--slides-body-m padded", padded.bodyM(), 47.04}, |
| + {"--slides-body-s padded", padded.bodyS(), 36.96}, |
| + {"--slides-code padded", padded.code(), 38.64}, |
| + {"--slides-gap padded", padded.gap(), 43.68}, |
| + {"--slides-rule padded", padded.rule(), 8.4}, |
| + {"--slides-pad", pad, 120}, |
| + {"the section's own gap at 1920", sectionGap, 49.92}, |
| + } { |
| + if diff := c.got - c.want; diff > 1e-9 || diff < -1e-9 { |
| + t.Errorf("%s is %v, want %v", c.name, c.got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// image-full and canvas drop the section's padding, so they are drawn |
| +// at the bigger scale; every other template keeps it. |
| +func TestTheScaleFollowsTheSectionsPadding(t *testing.T) { |
| + for _, c := range []struct { |
| + layout, template string |
| + want scale |
| + }{ |
| + {"flow", "title", padded}, |
| + {"flow", "blank", padded}, |
| + {"flow", "image-full", full}, |
| + {"canvas", "blank", full}, |
| + {"canvas", "image-full", full}, |
| + } { |
| + if got := scaleFor(c.layout, c.template); got != c.want { |
| + t.Errorf("a %s %s slide is drawn at %v cqw, want %v", c.layout, c.template, got.cqw, c.want.cqw) |
| + } |
| + } |
| +} |
| + |
| +// A region name no template places falls back to where the body goes, |
| +// which is what the stylesheet's auto placement does with it. |
| +func TestAnUnplacedRegionFallsBackToTheBody(t *testing.T) { |
| + got := regionFor("title", "aside") |
| + want := regions["title"]["body"] |
| + if got != want { |
| + t.Errorf("an aside on a title slide is %+v, want the body's %+v", got, want) |
| + } |
| + if regionFor("a template from a later build", "body") != regions["blank"]["body"] { |
| + t.Error("an unknown template does not fall back to blank") |
| + } |
| +} |
diff --git a/internal/slides/layout/sizes.go b/internal/slides/layout/sizes.go| new file mode 100644 |
| index 0000000..b71d782 |
| --- /dev/null |
| +++ b/internal/slides/layout/sizes.go |
| @@ -0,0 +1,177 @@ |
| +package layout |
| + |
| +// sizes.go is internal/slides/static/slides.css evaluated at 1920 by |
| +// 1080. Every number here is the stylesheet's own declaration with the |
| +// unit worked out, written as the multiplication rather than as the |
| +// answer so the two can be read side by side. |
| +// |
| +// ── What a cqw is, and why there are two of them ────────────────────── |
| +// |
| +// A container query length is one percent of the query container's |
| +// CONTENT box, not of its border box. The section is the container and |
| +// it carries `padding: 6.25%` — 120px each side at 1920 — so on a |
| +// slide with padding one cqw is 16.8px, and on one without it |
| +// (`image-full` and every canvas slide both set `padding: 0`) it is |
| +// 19.2. That is a 12.5% difference in every type size on six of the |
| +// seven templates, which is why it is spelled out twice here instead |
| +// of being assumed once. |
| +// |
| +// Checked against Chromium rather than reasoned about: a `title` |
| +// slide's h1 computes to 110.88px (6.6 × 16.8) and a canvas slide's to |
| +// 126.72 (6.6 × 19.2). |
| +// |
| +// ── The one place the stylesheet cannot be followed ─────────────────── |
| +// |
| +// `[slides-slide] { gap: var(--slides-gap) }` is a cqw length written |
| +// ON the container, and an element is never its own query container — |
| +// slides.css's own header says so about the type scale, and its `gap` |
| +// is exactly that mistake. The browser resolves it against whatever |
| +// container is OUTSIDE the section, which in practice is the viewport: |
| +// a slide filling a 1920 screen gets 49.92, the same slide in a 780px |
| +// test window gets 20.27, and the space between a heading and a body |
| +// region moves with the browser window. |
| +// |
| +// This engine draws a slide at 1920 filling its frame, which is the |
| +// projector case, so sectionGap below is what a fullscreen 1920 slide |
| +// gets. It is a divergence from a slide viewed at any other size, and |
| +// it is the stylesheet's to fix (moving the grid off the container), |
| +// not this package's to imitate. |
| + |
| +// The frame. 16:9, and the only aspect this build has (§9). |
| +const ( |
| + FrameW = 1920.0 |
| + FrameH = 1080.0 |
| + |
| + // pad is `--slides-pad: 6.25%`. A percentage padding resolves |
| + // against the containing block's INLINE size on all four sides, so |
| + // the top and bottom of a slide are 120 as well, not 67.5. |
| + pad = 0.0625 * FrameW |
| + |
| + // cqwPadded is one cqw inside a section with padding, cqwFull one |
| + // inside a section without it. |
| + cqwPadded = (FrameW - 2*pad) / 100 |
| + cqwFull = FrameW / 100 |
| + |
| + // sectionGap is `[slides-slide] { gap }` at fullscreen — see the |
| + // header. sectionColGap is two-column's |
| + // `column-gap: calc(var(--slides-gap) * 1.6)`, which is written on |
| + // the container too and resolves the same way. |
| + sectionGap = 2.6 * cqwFull |
| + sectionColGap = sectionGap * 1.6 |
| +) |
| + |
| +// scale is the type and spacing scale at one cqw. Everything a |
| +// DESCENDANT of the section is sized in goes through one of these; the |
| +// section's own two declarations are the constants above. |
| +type scale struct{ cqw float64 } |
| + |
| +var ( |
| + // padded is the scale on the five templates that keep the |
| + // section's padding, and full the one on `image-full` and on every |
| + // canvas slide. |
| + padded = scale{cqwPadded} |
| + full = scale{cqwFull} |
| +) |
| + |
| +// scaleFor is the scale a slide is drawn at: which of the two above |
| +// depends on whether the section has padding. |
| +func scaleFor(layout, template string) scale { |
| + if layout == "canvas" || template == "image-full" { |
| + return full |
| + } |
| + return padded |
| +} |
| + |
| +// The type scale, from `[slides-slide]`'s custom properties. |
| +func (s scale) t1() float64 { return 6.6 * s.cqw } // --slides-t-1 |
| +func (s scale) t2() float64 { return 5.0 * s.cqw } // --slides-t-2 |
| +func (s scale) t3() float64 { return 4.0 * s.cqw } // --slides-t-3 |
| +func (s scale) bodyL() float64 { return 3.4 * s.cqw } // --slides-body-l |
| +func (s scale) bodyM() float64 { return 2.8 * s.cqw } // --slides-body-m |
| +func (s scale) bodyS() float64 { return 2.2 * s.cqw } // --slides-body-s |
| +func (s scale) code() float64 { return 2.3 * s.cqw } // --slides-code |
| + |
| +// gap is `--slides-gap` as a DESCENDANT reads it: the space between |
| +// two blocks in a region, between two columns of a columns block, and |
| +// the padding of image-full's heading scrim. |
| +func (s scale) gap() float64 { return 2.6 * s.cqw } |
| + |
| +// rule is `--slides-rule`: a divider's thickness and the section |
| +// template's accent bar. |
| +func (s scale) rule() float64 { return 0.5 * s.cqw } |
| + |
| +// bodySize maps a size step to this scale's body sizes. |
| +func (s scale) bodySize(step string) float64 { |
| + switch step { |
| + case "s": |
| + return s.bodyS() |
| + case "l": |
| + return s.bodyL() |
| + } |
| + return s.bodyM() |
| +} |
| + |
| +// Line heights, as the stylesheet sets them: 1.3 on the section and |
| +// inherited by everything, 1.1 on a heading, 1.45 in a code block. |
| +// |
| +// These are ratios of the font size, which is what CSS `line-height: N` |
| +// means, and they are the reason this package does not use the faces' |
| +// own default leading (fonts.Metrics returns it and this ignores it): |
| +// the browser is following the stylesheet, so agreeing with the browser |
| +// means following the stylesheet too. |
| +const ( |
| + leadBody = 1.3 |
| + leadHeading = 1.1 |
| + leadCode = 1.45 |
| +) |
| + |
| +// trackHeading is `letter-spacing`, in ems, and a heading is the only |
| +// place the stylesheet sets it. Small, and not negligible: over a |
| +// thirty-character title it is nearly a word's width. |
| +const trackHeading = -0.01 |
| + |
| +// Gaps inside a block, all of them relative to the block's own font |
| +// size because that is how the stylesheet writes them. |
| +const ( |
| + paraGapEm = 0.6 // `[slides-block] p + p { margin-block-start: 0.6em }` |
| + listGapEm = 0.4 // `[data-block="list"] { gap: 0.4em }` |
| + quoteGap = 0.6 // `[data-block="quote"] { gap: 0.6em }` |
| + citeEm = 0.62 // `figcaption { font-size: 0.62em }` |
| + inlineEm = 0.88 // `:not(pre) > code { font-size: 0.88em }` |
| +) |
| + |
| +// listIndentEm is `padding-inline-start` per indent step (§2.6: a |
| +// nested list is a second list block with a bigger indent, never a |
| +// nested <ul>). Four entries, because the stylesheet has four rules. |
| +var listIndentEm = [4]float64{1.4, 3.0, 4.6, 6.2} |
| + |
| +// Code block geometry: `padding: 1em; border-radius: 0.6em`. |
| +const ( |
| + codePadEm = 1.0 |
| + codeRadiusEm = 0.6 |
| +) |
| + |
| +// Table geometry: `padding: 0.45em 0.7em` on every cell and a 1px rule |
| +// under every row. |
| +const ( |
| + cellPadYEm = 0.45 |
| + cellPadXEm = 0.7 |
| + cellRule = 1.0 |
| +) |
| + |
| +// shapeMinBlockCqw is `[data-block="shape"] { min-block-size: 8cqw }`, |
| +// which is the floor a shape shrinks to and no further. |
| +const shapeMinBlockCqw = 8.0 |
| + |
| +// The mixes the stylesheet makes with color-mix(), as fractions of the |
| +// foreground mixed into the background. |
| +const ( |
| + mixCodePanel = 0.08 // a code block's panel |
| + mixTableRule = 0.22 // a table's row rule |
| + mixDivider = 0.30 // a divider |
| + mixHighlight = 0.26 // a <mark>, mixed from the ACCENT not the fg |
| + |
| + // imageFallback is the ratio an image with no registered size is |
| + // drawn at — the frame's own, which is the least surprising guess. |
| + imageFallback = 9.0 / 16.0 |
| +) |
diff --git a/internal/slides/layout/testdata/blank.golden.json b/internal/slides/layout/testdata/blank.golden.json| new file mode 100644 |
| index 0000000..a454b29 |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/blank.golden.json |
| @@ -0,0 +1,187 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "rect", |
| + "x": 120, |
| + "y": 120, |
| + "w": 1680, |
| + "h": 245.364, |
| + "fill": "#e8e5df", |
| + "radius": 23.184 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 158.64, |
| + "y": 196.9366, |
| + "runs": [ |
| + { |
| + "s": "func main() {", |
| + "face": "LiberationMono-Regular", |
| + "size": 38.64, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 158.64, |
| + "y": 252.9646, |
| + "runs": [ |
| + { |
| + "s": " fmt.Println(\"hi \u003cthere\u003e\")", |
| + "face": "LiberationMono-Regular", |
| + "size": 38.64, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 158.64, |
| + "y": 308.9926, |
| + "runs": [ |
| + { |
| + "s": "}", |
| + "face": "LiberationMono-Regular", |
| + "size": 38.64, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 145.872, |
| + "y": 462.5133, |
| + "runs": [ |
| + { |
| + "s": "Region", |
| + "face": "LiberationSans-Bold", |
| + "size": 36.96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 985.872, |
| + "y": 462.5133, |
| + "runs": [ |
| + { |
| + "s": "Holds", |
| + "face": "LiberationSans-Bold", |
| + "size": 36.96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "line", |
| + "x1": 120, |
| + "y1": 490.356, |
| + "x2": 1800, |
| + "y2": 490.356, |
| + "stroke": "#8a5410", |
| + "width": 1 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 145.872, |
| + "y": 544.8253, |
| + "runs": [ |
| + { |
| + "s": "heading", |
| + "face": "LiberationSans-Regular", |
| + "size": 36.96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 985.872, |
| + "y": 544.8253, |
| + "runs": [ |
| + { |
| + "s": "the title", |
| + "face": "LiberationSans-Regular", |
| + "size": 36.96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "line", |
| + "x1": 120, |
| + "y1": 572.668, |
| + "x2": 1800, |
| + "y2": 572.668, |
| + "stroke": "#c9c6c0", |
| + "width": 1 |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 120, |
| + "y": 617.348, |
| + "w": 1680, |
| + "h": 8.4, |
| + "fill": "#b7b5af", |
| + "radius": 4.2 |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 136.8, |
| + "y": 801.812, |
| + "w": 1646.4, |
| + "h": 131.712, |
| + "fill": "#e8e2d6", |
| + "stroke": "#8a7f6a", |
| + "strokeWidth": 2, |
| + "radius": 201.6 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 753.4076, |
| + "y": 883.9758, |
| + "runs": [ |
| + { |
| + "s": "A labelled rectangle", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "ellipse", |
| + "x": 136.8, |
| + "y": 979.892, |
| + "w": 1646.4, |
| + "h": 131.712, |
| + "fill": "#cfd9e8", |
| + "stroke": "#5a6f8a", |
| + "strokeWidth": 2 |
| + }, |
| + { |
| + "op": "line", |
| + "x1": 136.8, |
| + "y1": 1223.828, |
| + "x2": 1783.2, |
| + "y2": 1223.828, |
| + "stroke": "#5a6f8a", |
| + "width": 3 |
| + }, |
| + { |
| + "op": "line", |
| + "x1": 136.8, |
| + "y1": 1401.908, |
| + "x2": 1783.2, |
| + "y2": 1401.908, |
| + "stroke": "#1c1a17", |
| + "width": 1, |
| + "arrow": true |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/canvas-reflow.golden.json b/internal/slides/layout/testdata/canvas-reflow.golden.json| new file mode 100644 |
| index 0000000..767b342 |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/canvas-reflow.golden.json |
| @@ -0,0 +1,83 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "text", |
| + "x": 115.2, |
| + "y": 220.4516, |
| + "runs": [ |
| + { |
| + "s": "On a phone this one reflows", |
| + "face": "LiberationSerif-Bold", |
| + "size": 126.72, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 1234.56, |
| + "y": 350.352, |
| + "w": 564.48, |
| + "h": 465.696, |
| + "fill": "#e0d7e8", |
| + "stroke": "#75608a", |
| + "strokeWidth": 2, |
| + "radius": 69.12 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 1361.4131, |
| + "y": 601.8375, |
| + "runs": [ |
| + { |
| + "s": "Desktop only", |
| + "face": "LiberationSans-Regular", |
| + "size": 53.76, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 115.2, |
| + "y": 399.1815, |
| + "runs": [ |
| + { |
| + "s": "Setting phone to reflow lays the blocks", |
| + "face": "LiberationSans-Regular", |
| + "size": 53.76, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 115.2, |
| + "y": 469.0695, |
| + "runs": [ |
| + { |
| + "s": "out as one column.", |
| + "face": "LiberationSans-Regular", |
| + "size": 53.76, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 115.2, |
| + "y": 917.5815, |
| + "runs": [ |
| + { |
| + "s": "A footnote, last by z.", |
| + "face": "LiberationSans-Regular", |
| + "size": 53.76, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/canvas.golden.json b/internal/slides/layout/testdata/canvas.golden.json| new file mode 100644 |
| index 0000000..4178fb7 |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/canvas.golden.json |
| @@ -0,0 +1,70 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "rect", |
| + "x": 160.512, |
| + "y": 371.952, |
| + "w": 677.376, |
| + "h": 465.696, |
| + "fill": "#e8e2d6", |
| + "stroke": "#8a7f6a", |
| + "strokeWidth": 2, |
| + "radius": 82.944 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 115.2, |
| + "y": 198.8516, |
| + "runs": [ |
| + { |
| + "s": "Canvas: put it where you want", |
| + "face": "LiberationSerif-Bold", |
| + "size": 126.72, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 115.2, |
| + "y": 338.2436, |
| + "runs": [ |
| + { |
| + "s": "it", |
| + "face": "LiberationSerif-Bold", |
| + "size": 126.72, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 1113.6, |
| + "y": 442.3815, |
| + "runs": [ |
| + { |
| + "s": "Every box is a fraction of the", |
| + "face": "LiberationSans-Regular", |
| + "size": 53.76, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 1113.6, |
| + "y": 512.2695, |
| + "runs": [ |
| + { |
| + "s": "16:9 frame.", |
| + "face": "LiberationSans-Regular", |
| + "size": 53.76, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/image-full.golden.json b/internal/slides/layout/testdata/image-full.golden.json| new file mode 100644 |
| index 0000000..4e1556a |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/image-full.golden.json |
| @@ -0,0 +1,49 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "image", |
| + "x": 0, |
| + "y": 0, |
| + "w": 1920, |
| + "h": 1080, |
| + "asset": "asset0002bbb", |
| + "fit": "cover", |
| + "alt": "" |
| + }, |
| + { |
| + "op": "image", |
| + "x": 0, |
| + "y": 0, |
| + "w": 1920, |
| + "h": 1080, |
| + "asset": "asset0001aaa", |
| + "fit": "cover", |
| + "alt": "A bar chart with no bars" |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 120, |
| + "y": 804.48, |
| + "w": 1064.3381, |
| + "h": 155.52, |
| + "fill": "#faf7f0", |
| + "radius": 19.2 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 169.92, |
| + "y": 914.6306, |
| + "runs": [ |
| + { |
| + "s": "A picture would go here", |
| + "face": "LiberationSerif-Bold", |
| + "size": 96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/quote.golden.json b/internal/slides/layout/testdata/quote.golden.json| new file mode 100644 |
| index 0000000..f64415a |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/quote.golden.json |
| @@ -0,0 +1,46 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "text", |
| + "x": 491.6746, |
| + "y": 460.679, |
| + "runs": [ |
| + { |
| + "s": "A deck is a document, not a drawing.", |
| + "face": "LiberationSans-Italic", |
| + "size": 57.12, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 807.4397, |
| + "y": 547.5735, |
| + "runs": [ |
| + { |
| + "s": "— The design spec", |
| + "face": "LiberationSans-Regular", |
| + "size": 35.4144, |
| + "fill": "#8a5410" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 599.6113, |
| + "y": 658.9257, |
| + "runs": [ |
| + { |
| + "s": "Said by nobody in particular.", |
| + "face": "LiberationSans-Italic", |
| + "size": 57.12, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/section.golden.json b/internal/slides/layout/testdata/section.golden.json| new file mode 100644 |
| index 0000000..aca8170 |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/section.golden.json |
| @@ -0,0 +1,29 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#2b1030", |
| + "ops": [ |
| + { |
| + "op": "text", |
| + "x": 759.7024, |
| + "y": 551.3712, |
| + "runs": [ |
| + { |
| + "s": "Part two", |
| + "face": "LiberationSerif-Bold", |
| + "size": 110.88, |
| + "fill": "#faf7f0" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 808.8, |
| + "y": 618.624, |
| + "w": 302.4, |
| + "h": 8.4, |
| + "fill": "#faf7f0", |
| + "radius": 4.2 |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/title-body.golden.json b/internal/slides/layout/testdata/title-body.golden.json| new file mode 100644 |
| index 0000000..24d48a3 |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/title-body.golden.json |
| @@ -0,0 +1,237 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 218.3952, |
| + "runs": [ |
| + { |
| + "s": "Text and marks", |
| + "face": "LiberationSerif-Bold", |
| + "size": 110.88, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 638.2871, |
| + "y": 296.1878, |
| + "w": 189.3406, |
| + "h": 52.5525, |
| + "fill": "#ddcdb6", |
| + "radius": 9.408 |
| + }, |
| + { |
| + "op": "rect", |
| + "x": 840.5008, |
| + "y": 296.1878, |
| + "w": 211.1479, |
| + "h": 52.5525, |
| + "fill": "#ddcdb6", |
| + "radius": 8.279 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 338.7718, |
| + "runs": [ |
| + { |
| + "s": "Marks: ", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": "bold", |
| + "face": "LiberationSans-Bold", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": ", ", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": "italic", |
| + "face": "LiberationSans-Italic", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": ", ", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": "code", |
| + "face": "LiberationMono-Regular", |
| + "size": 41.3952, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": ", ", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": "highlight", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": ", ", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": "all four", |
| + "face": "LiberationMono-BoldItalic", |
| + "size": 41.3952, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": " and a ", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + }, |
| + { |
| + "s": "link", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#8a5410", |
| + "href": "https://amadan.net/oficina/slides" |
| + }, |
| + { |
| + "s": " \u003cnot markup\u003e.", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "line", |
| + "x1": 1189.2927, |
| + "y1": 345.8278, |
| + "x2": 1259.8757, |
| + "y2": 345.8278, |
| + "stroke": "#8a5410", |
| + "width": 3.2928 |
| + }, |
| + { |
| + "op": "text", |
| + "x": 185.856, |
| + "y": 443.6038, |
| + "runs": [ |
| + { |
| + "s": "One item per paragraph", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 152.9234, |
| + "y": 443.6038, |
| + "runs": [ |
| + { |
| + "s": "•", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#8a5410" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 185.856, |
| + "y": 523.5718, |
| + "runs": [ |
| + { |
| + "s": "Bullets or numbers", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 152.9234, |
| + "y": 523.5718, |
| + "runs": [ |
| + { |
| + "s": "•", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#8a5410" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 230.88, |
| + "y": 618.3573, |
| + "runs": [ |
| + { |
| + "s": "First", |
| + "face": "LiberationSans-Regular", |
| + "size": 36.96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 187.1199, |
| + "y": 618.3573, |
| + "runs": [ |
| + { |
| + "s": "1.", |
| + "face": "LiberationSans-Regular", |
| + "size": 36.96, |
| + "fill": "#8a5410" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 230.88, |
| + "y": 681.1893, |
| + "runs": [ |
| + { |
| + "s": "Second", |
| + "face": "LiberationSans-Regular", |
| + "size": 36.96, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 187.1199, |
| + "y": 681.1893, |
| + "runs": [ |
| + { |
| + "s": "2.", |
| + "face": "LiberationSans-Regular", |
| + "size": 36.96, |
| + "fill": "#8a5410" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/title.golden.json b/internal/slides/layout/testdata/title.golden.json| new file mode 100644 |
| index 0000000..efa48fb |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/title.golden.json |
| @@ -0,0 +1,33 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "text", |
| + "x": 570.1788, |
| + "y": 515.3232, |
| + "runs": [ |
| + { |
| + "s": "Renderer fixture", |
| + "face": "LiberationSerif-Bold", |
| + "size": 110.88, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 386.8895, |
| + "y": 645.7463, |
| + "runs": [ |
| + { |
| + "s": "One section per slide, one element per block.", |
| + "face": "LiberationSans-Regular", |
| + "size": 57.12, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/testdata/two-column.golden.json b/internal/slides/layout/testdata/two-column.golden.json| new file mode 100644 |
| index 0000000..a568550 |
| --- /dev/null |
| +++ b/internal/slides/layout/testdata/two-column.golden.json |
| @@ -0,0 +1,124 @@ |
| +{ |
| + "w": 1920, |
| + "h": 1080, |
| + "background": "#faf7f0", |
| + "ops": [ |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 194.5418, |
| + "runs": [ |
| + { |
| + "s": "Regions, not coordinates", |
| + "face": "LiberationSerif-Bold", |
| + "size": 84, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 338.7718, |
| + "runs": [ |
| + { |
| + "s": "A flow slide names regions and blocks", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 399.9238, |
| + "runs": [ |
| + { |
| + "s": "stack inside the one they are in.", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 504.7558, |
| + "runs": [ |
| + { |
| + "s": "First column,", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 565.9078, |
| + "runs": [ |
| + { |
| + "s": "second in the", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 120, |
| + "y": 627.0598, |
| + "runs": [ |
| + { |
| + "s": "array.", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 541.872, |
| + "y": 504.7558, |
| + "runs": [ |
| + { |
| + "s": "Second column,", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 541.872, |
| + "y": 565.9078, |
| + "runs": [ |
| + { |
| + "s": "first in the array.", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + }, |
| + { |
| + "op": "text", |
| + "x": 999.936, |
| + "y": 338.7718, |
| + "runs": [ |
| + { |
| + "s": "The aside is the second column.", |
| + "face": "LiberationSans-Regular", |
| + "size": 47.04, |
| + "fill": "#1c1a17" |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/slides/layout/text.go b/internal/slides/layout/text.go| new file mode 100644 |
| index 0000000..9d9adeb |
| --- /dev/null |
| +++ b/internal/slides/layout/text.go |
| @@ -0,0 +1,356 @@ |
| +package layout |
| + |
| +import ( |
| + "strings" |
| + "unicode" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| +) |
| + |
| +// text.go measures and breaks lines. It is the one place spec §4.2 |
| +// admits divergence from the browser, so it is worth saying exactly |
| +// what it does and does not do. |
| +// |
| +// Greedy: a line takes words until the next one would not fit, then |
| +// breaks. No hyphenation dictionary, no Knuth-Plass, no shaping — the |
| +// same unkerned sum of advance widths a browser without GPOS would |
| +// get (internal/slides/fonts' parser reads no GPOS, and says so). |
| +// Break opportunities are spaces and the inside of a hyphenated word, |
| +// which are the two a browser takes without being asked. |
| +// |
| +// A word wider than the box is placed on its own line and left to |
| +// overflow, rather than being broken mid-word. That is what the |
| +// stylesheet does (`overflow-wrap` is never set, so a long URL runs |
| +// past its column), and print has the same rule as screen: never clip. |
| + |
| +// style is how one stretch of text is set: a face, a size, a colour, |
| +// and the two things that are drawn around it rather than in it. |
| +type style struct { |
| + face fonts.Face |
| + size float64 |
| + fill Fill |
| + href string |
| + // track is letter-spacing in ems (headings only). |
| + track float64 |
| + // mark is §2.6's highlight, drawn as a rectangle behind the run in |
| + // markFill (the stylesheet mixes it from the accent). |
| + mark bool |
| + markFill Fill |
| +} |
| + |
| +// token is one word or one run of spaces, with the style it is set in |
| +// and its measured width. Splitting into tokens before wrapping is |
| +// what lets a line break fall between two runs with different marks — |
| +// "very **bold** words" wraps between "very" and "bold" like any other |
| +// text. |
| +type token struct { |
| + s string |
| + st style |
| + space bool |
| + w float64 |
| +} |
| + |
| +// line is one assembled line: its tokens, its width, and the box it |
| +// occupies. asc and desc are the strut's — the block's own face at the |
| +// block's own size — because that is what CSS centres the content box |
| +// of a line against. |
| +type line struct { |
| + toks []token |
| + w float64 |
| + height float64 |
| + // gapBefore is the space above this line that is not leading: the |
| + // margin between two paragraphs of one block. |
| + gapBefore float64 |
| + asc, desc float64 |
| + overflowed bool |
| +} |
| + |
| +// block is a measured, wrapped run of text: the lines and how tall |
| +// they are together. Nothing in it is positioned yet. |
| +type textBlock struct { |
| + lines []line |
| + h float64 |
| +} |
| + |
| +// measure is the width of s in st, letter-spacing included, and it is |
| +// the only place a rune the faces cannot draw is noticed. |
| +func (e *engine) measure(st style, s string) float64 { |
| + w, missing := fonts.Advance(st.face, st.size, s) |
| + if len(missing) > 0 { |
| + e.noteGlyphs(missing) |
| + } |
| + if st.track != 0 { |
| + w += st.track * st.size * float64(len([]rune(s))) |
| + } |
| + return w |
| +} |
| + |
| +// styleFor turns a run's marks into a style over a base one (§2.6). |
| +// |
| +// Bold and italic pick a different face of the same family. Code |
| +// changes family AND size, because the stylesheet does |
| +// (`:not(pre) > code` is 0.88em in the mono face). A link takes the |
| +// accent colour and carries its href so the caller can underline it — |
| +// §8 does not let colour alone say "this is a link". |
| +func (e *engine) styleFor(base style, r deck.Run) style { |
| + st := base |
| + if r.B { |
| + st.face.Bold = true |
| + } |
| + if r.I { |
| + st.face.Italic = true |
| + } |
| + if r.C { |
| + st.face.Family = fonts.Mono |
| + st.size = base.size * inlineEm |
| + } |
| + if r.H { |
| + st.mark, st.markFill = true, e.highlight |
| + } |
| + if r.Href != "" && safeHref(r.Href) { |
| + st.fill = e.accent |
| + st.href = r.Href |
| + } |
| + return st |
| +} |
| + |
| +// tokenize splits one paragraph's runs into words and spaces. |
| +func (e *engine) tokenize(runs []deck.Run, base style) []token { |
| + var out []token |
| + for _, r := range runs { |
| + st := e.styleFor(base, r) |
| + for _, piece := range splitRuns(r.T) { |
| + out = append(out, token{ |
| + s: piece, |
| + st: st, |
| + space: isSpaceRun(piece), |
| + w: e.measure(st, piece), |
| + }) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// splitRuns cuts a string into alternating words and runs of |
| +// whitespace, and cuts a word after each hyphen — "context-free" is |
| +// two tokens, so a line may end on the hyphen the way a browser lets |
| +// it. |
| +func splitRuns(s string) []string { |
| + var out []string |
| + var cur strings.Builder |
| + var inSpace bool |
| + flush := func() { |
| + if cur.Len() > 0 { |
| + out = append(out, cur.String()) |
| + cur.Reset() |
| + } |
| + } |
| + for _, r := range s { |
| + sp := unicode.IsSpace(r) |
| + if cur.Len() > 0 && sp != inSpace { |
| + flush() |
| + } |
| + inSpace = sp |
| + cur.WriteRune(r) |
| + if !sp && r == '-' { |
| + flush() |
| + } |
| + } |
| + flush() |
| + return out |
| +} |
| + |
| +func isSpaceRun(s string) bool { |
| + for _, r := range s { |
| + if !unicode.IsSpace(r) { |
| + return false |
| + } |
| + } |
| + return s != "" |
| +} |
| + |
| +// wrap breaks t into lines no wider than w, one paragraph after |
| +// another with paraGap between them, and returns the measured block. |
| +// |
| +// lead is the CSS line-height ratio, applied to the block's own size, |
| +// so every line of a block is the same height whatever is on it — |
| +// which is what a `line-height: 1.3` inherited as a NUMBER does. |
| +func (e *engine) wrap(t deck.Text, base style, w, lead, paraGap float64) *textBlock { |
| + asc, desc, _ := fonts.Metrics(base.face, base.size) |
| + lineH := lead * base.size |
| + out := &textBlock{} |
| + |
| + // pending is the paragraph margin owed to the next line emitted. |
| + var pending float64 |
| + emit := func(toks []token, width float64, over bool) { |
| + out.lines = append(out.lines, line{ |
| + toks: toks, w: width, height: lineH, gapBefore: pending, |
| + asc: asc, desc: desc, overflowed: over, |
| + }) |
| + pending = 0 |
| + } |
| + |
| + for i, para := range t { |
| + if i > 0 { |
| + pending = paraGap |
| + out.h += paraGap |
| + } |
| + start := len(out.lines) |
| + toks := e.tokenize(para, base) |
| + |
| + var cur []token |
| + var curW float64 |
| + flush := func() { |
| + // A line never ends on a space: the trailing space is |
| + // dropped, as a browser drops it at a soft break. |
| + for len(cur) > 0 && cur[len(cur)-1].space { |
| + curW -= cur[len(cur)-1].w |
| + cur = cur[:len(cur)-1] |
| + } |
| + emit(cur, curW, curW > w) |
| + cur, curW = nil, 0 |
| + } |
| + for _, tk := range toks { |
| + if tk.space { |
| + // A line never opens with a space either. |
| + if len(cur) == 0 { |
| + continue |
| + } |
| + cur = append(cur, tk) |
| + curW += tk.w |
| + continue |
| + } |
| + if len(cur) > 0 && curW+tk.w > w { |
| + flush() |
| + } |
| + cur = append(cur, tk) |
| + curW += tk.w |
| + } |
| + if len(cur) > 0 || len(out.lines) == start { |
| + // The second half puts an empty line box under an empty |
| + // paragraph, which is what `<p></p>` is on screen. |
| + flush() |
| + } |
| + out.h += float64(len(out.lines)-start) * lineH |
| + } |
| + return out |
| +} |
| + |
| +// paint places a wrapped block with its top-left at (x, y) in a column |
| +// w wide, and returns the ops that draw it. |
| +// |
| +// The baseline is where CSS puts it: the text's content box is centred |
| +// in the line box (half the leading above, half below) and the |
| +// baseline is one ascent down from the top of that box. |
| +func (e *engine) paint(tb *textBlock, x, y, w float64, align Align) []Op { |
| + var ops []Op |
| + top := y |
| + for _, ln := range tb.lines { |
| + lineX := x |
| + switch align { |
| + case Center: |
| + lineX = x + (w-ln.w)/2 |
| + case End: |
| + lineX = x + w - ln.w |
| + } |
| + top += ln.gapBefore |
| + baseline := top + (ln.height-(ln.asc+ln.desc))/2 + ln.asc |
| + ops = append(ops, lineOps(lineX, baseline, ln)...) |
| + top += ln.height |
| + } |
| + return ops |
| +} |
| + |
| +// lineOps draws one line: the marks behind the words, the words, and |
| +// the underline beneath a link. |
| +// |
| +// Adjacent tokens in the same style become one Run, because a paint |
| +// list is read by people as well as by the serialisers and a line |
| +// spelled one word per run is unreadable. The marks and underlines are |
| +// separate ops for the reason §8 gives: a link that is only a colour |
| +// is not a link anybody colour-blind can see, and a PDF annotation |
| +// draws nothing on the page. |
| +func lineOps(x, baseline float64, ln line) []Op { |
| + var marks, unders []Op |
| + var runs []Run |
| + cx := x |
| + |
| + // A run of tokens sharing a style, accumulated then flushed. |
| + var pend strings.Builder |
| + var pendSt style |
| + var pendW, pendX float64 |
| + flush := func() { |
| + if pend.Len() == 0 { |
| + return |
| + } |
| + if pendSt.mark { |
| + // `mark` is a background behind the words, one ascent up |
| + // and one descent down, with the stylesheet's own 0.15em |
| + // of breathing room either side. |
| + pad := 0.15 * pendSt.size |
| + marks = append(marks, Rect{ |
| + X: pendX - pad, Y: baseline - ln.asc, W: pendW + 2*pad, H: ln.asc + ln.desc, |
| + Fill: pendSt.markFill, Radius: 0.2 * pendSt.size, |
| + }) |
| + } |
| + if pendSt.href != "" { |
| + // `text-underline-offset: 0.15em`, |
| + // `text-decoration-thickness: 0.07em`. |
| + off := 0.15 * pendSt.size |
| + unders = append(unders, Line{ |
| + X1: pendX, Y1: baseline + off, X2: pendX + pendW, Y2: baseline + off, |
| + Stroke: pendSt.fill, W: 0.07 * pendSt.size, |
| + }) |
| + } |
| + runs = append(runs, Run{ |
| + S: pend.String(), Face: pendSt.face, Size: pendSt.size, |
| + Fill: pendSt.fill, Href: pendSt.href, |
| + }) |
| + pend.Reset() |
| + pendW = 0 |
| + } |
| + |
| + for _, tk := range ln.toks { |
| + if pend.Len() > 0 && tk.st != pendSt { |
| + flush() |
| + } |
| + if pend.Len() == 0 { |
| + pendSt, pendX = tk.st, cx |
| + } |
| + pend.WriteString(tk.s) |
| + pendW += tk.w |
| + cx += tk.w |
| + } |
| + flush() |
| + |
| + if len(runs) == 0 { |
| + return nil |
| + } |
| + // Marks first (they are behind), then the words, then the |
| + // underlines, which sit under the letters and over the mark. |
| + out := append([]Op{}, marks...) |
| + out = append(out, Text{X: x, Y: baseline, Runs: runs}) |
| + return append(out, unders...) |
| +} |
| + |
| +// safeHref is render's own rule, restated: a link that does not point |
| +// at http, https or mailto is not drawn as a link. It is restated |
| +// rather than exported because the two answers are about different |
| +// media — this one decides whether to underline something on a page, |
| +// not whether to write an <a> — and one of them changing should not |
| +// silently change the other. |
| +func safeHref(raw string) bool { |
| + if raw == "" || strings.TrimSpace(raw) != raw { |
| + return false |
| + } |
| + scheme, _, ok := strings.Cut(raw, ":") |
| + if !ok { |
| + return false |
| + } |
| + switch strings.ToLower(scheme) { |
| + case "http", "https", "mailto": |
| + return true |
| + } |
| + return false |
| +} |
diff --git a/internal/slides/render/render.go b/internal/slides/render/render.go| index dc07e37..5f34edc 100644 |
| --- a/internal/slides/render/render.go |
| +++ b/internal/slides/render/render.go |
| @@ -241,14 +241,24 @@ func (c *renderer) assetRef(id string) string { |
| // another one's pictures. |
| func (c *renderer) assetPath(ref string) string { return c.assetBase + "/" + ref } |
| -// themeID resolves a deck's theme name to one this build has. |
| -func (c *renderer) themeID(name string) string { |
| +// ThemeID resolves a theme name to one this build has: a deck naming a |
| +// theme that was renamed out from under it renders as the default |
| +// rather than as nothing. |
| +// |
| +// Exported because the layout engine needs the same answer — it asks |
| +// internal/slides/fonts for the face that theme sets its headings in, |
| +// and asking with an unresolved name would measure a slide in a face |
| +// the browser is not using. |
| +func ThemeID(name string) string { |
| if _, ok := themeVars[name]; ok { |
| return name |
| } |
| return defaultThemeID |
| } |
| +// themeID is ThemeID under the renderer's own hand. |
| +func (c *renderer) themeID(name string) string { return ThemeID(name) } |
| + |
| // sectionStyle is the theme's custom properties, plus the three things |
| // a deck may override: meta.accent, meta.fonts, and the slide's own |
| // background. Each override is allowlisted before it is written, and a |
| @@ -262,12 +272,56 @@ func (c *renderer) themeID(name string) string { |
| // clear the thresholds against it, and an accent a deck names is used |
| // only if it clears 3:1 against the background it will actually sit on. |
| func (c *renderer) sectionStyle(s deck.Slide) string { |
| - vars := themeVars[c.themeID(c.deck.Meta.Theme)] |
| + out := slideVars(c.deck, s) |
| + |
| + // The slide's own backdrop when it is a picture (§2.2): a custom |
| + // property the stylesheet paints, pointing at THIS surface's asset |
| + // base — which is why it is here and not in slideVars. A colour is |
| + // the same on every surface; a URL is not. |
| + // |
| + // Unquoted url() because safeAssetID and safeRef between them have |
| + // already ruled out everything that would need quoting. |
| + var image string |
| + if s.Background.Kind == "image" { |
| + if ref := c.assetRef(safeAssetID(s.Background.Value)); ref != "" { |
| + image = "--slides-bg-image:url(" + c.assetPath(ref) + ")" |
| + } |
| + } |
| + |
| + parts := make([]string, 0, len(varOrder)+1) |
| + for _, k := range varOrder { |
| + parts = append(parts, k+":"+out[k]) |
| + } |
| + if image != "" { |
| + parts = append(parts, image) |
| + } |
| + return strings.Join(parts, ";") |
| +} |
| + |
| +// SlideVars is the palette d's slide at index is drawn in: the theme's |
| +// custom properties with the deck's and the slide's own overrides |
| +// already judged. It is what Slide writes on the section, so the |
| +// layout engine (internal/slides/layout, and through it the SVG and |
| +// the PDF) paints the slide the browser paints rather than forming a |
| +// second opinion about it. False for an index that is not a slide. |
| +// |
| +// Colours and font stacks only — a background IMAGE is not here, |
| +// because its URL depends on which surface is asking (Options). |
| +func SlideVars(d *deck.Deck, index int) (map[string]string, bool) { |
| + if d == nil || index < 0 || index >= len(d.Slides) { |
| + return nil, false |
| + } |
| + return slideVars(d, d.Slides[index]), true |
| +} |
| + |
| +// slideVars is sectionStyle's arithmetic without the serialising. |
| +func slideVars(d *deck.Deck, s deck.Slide) map[string]string { |
| + vars := themeVars[ThemeID(d.Meta.Theme)] |
| out := make(map[string]string, len(vars)+1) |
| for k, v := range vars { |
| out[k] = v |
| } |
| - if f := c.deck.Meta.Fonts; f != nil { |
| + if f := d.Meta.Fonts; f != nil { |
| if h := safeFontStack(f.Heading); h != "" { |
| out["--slides-font-heading"] = h |
| } |
| @@ -281,9 +335,7 @@ func (c *renderer) sectionStyle(s deck.Slide) string { |
| // says one thing about the background and not two. Hex only: a |
| // colour keyword is a colour this package cannot measure, and an |
| // unmeasurable background cannot be given a readable foreground. |
| - var image string |
| - switch s.Background.Kind { |
| - case "color": |
| + if s.Background.Kind == "color" { |
| if bg := safeHexColor(s.Background.Value); bg != "" && bg != out["--slides-bg"] { |
| out["--slides-bg"] = bg |
| // The theme's own text first, then the colour it was |
| @@ -300,32 +352,15 @@ func (c *renderer) sectionStyle(s deck.Slide) string { |
| out[k] = readableOn(bg, accentContrast, vars[k], out["--slides-fg"]) |
| } |
| } |
| - case "image": |
| - // An image becomes a custom property the stylesheet paints. It |
| - // points at this surface's own asset base — see imageBlock for |
| - // the same resolution. Unquoted url() because safeAssetID and |
| - // safeRef between them have already ruled out everything that |
| - // would need quoting. |
| - if ref := c.assetRef(safeAssetID(s.Background.Value)); ref != "" { |
| - image = "--slides-bg-image:url(" + c.assetPath(ref) + ")" |
| - } |
| } |
| // The deck's accent, last, so it is judged against the background |
| // this slide actually has rather than against the theme's. |
| - if accent := safeHexColor(c.deck.Meta.Accent); accent != "" && |
| + if accent := safeHexColor(d.Meta.Accent); accent != "" && |
| contrastRatio(accent, out["--slides-bg"]) >= accentContrast { |
| out["--slides-accent"] = accent |
| } |
| - |
| - parts := make([]string, 0, len(varOrder)+1) |
| - for _, k := range varOrder { |
| - parts = append(parts, k+":"+out[k]) |
| - } |
| - if image != "" { |
| - parts = append(parts, image) |
| - } |
| - return strings.Join(parts, ";") |
| + return out |
| } |
| // body is the section's children: region wrappers on a flow slide, |
diff --git a/internal/slides/render/render_test.go b/internal/slides/render/render_test.go| index b932b7a..2154465 100644 |
| --- a/internal/slides/render/render_test.go |
| +++ b/internal/slides/render/render_test.go |
| @@ -12,6 +12,7 @@ import ( |
| "testing" |
| "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| "amadan.net/oficina/slides/internal/slides/render" |
| ) |
| @@ -967,3 +968,99 @@ func TestPhoneBreakpointCollapsesEveryTemplateToOneColumn(t *testing.T) { |
| t.Errorf("no rule in the phone container block resets both grid-template-columns to a single track and grid-template-areas to none for two-column (or every flow slide); two-column's desktop `1fr 1fr` track survives the breakpoint, so body and aside sit side by side instead of stacking") |
| } |
| } |
| + |
| +// TestThemeFacesMatchTheStylesheetStacks is the join between the two |
| +// halves of "one font set everywhere": the browser sets a slide in |
| +// whatever --slides-font-heading names, and internal/slides/layout |
| +// measures it in whatever fonts.Get says that theme's headings are. If |
| +// those two ever disagree the PDF stops matching the screen, and |
| +// nothing else would notice — fonts cannot import render (render |
| +// imports fonts), so this test is the only place the two tables meet. |
| +func TestThemeFacesMatchTheStylesheetStacks(t *testing.T) { |
| + for _, th := range render.Themes() { |
| + vars, ok := render.ThemeVars(th.ID) |
| + if !ok { |
| + t.Fatalf("ThemeVars(%q) reported unknown", th.ID) |
| + } |
| + for _, c := range []struct { |
| + prop string |
| + role fonts.Role |
| + }{ |
| + {"--slides-font-heading", fonts.Heading}, |
| + {"--slides-font-body", fonts.Body}, |
| + } { |
| + stack := vars[c.prop] |
| + named, ok := fonts.FamilyForStack(stack) |
| + if !ok { |
| + t.Errorf("theme %q's %s is %q, which names no embedded family: the layout engine could not measure it", |
| + th.ID, c.prop, stack) |
| + continue |
| + } |
| + if got := fonts.Get(th.ID, c.role, false, false).Family; got != named { |
| + t.Errorf("theme %q sets %s to %q but fonts.Get measures with %s", |
| + th.ID, c.prop, stack, got.CSSName()) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// An unknown theme resolves to the default rather than to nothing, and |
| +// the layout engine has to ask with the resolved name — see ThemeID. |
| +func TestThemeIDResolvesToOneThisBuildHas(t *testing.T) { |
| + for _, th := range render.Themes() { |
| + if got := render.ThemeID(th.ID); got != th.ID { |
| + t.Errorf("ThemeID(%q) = %q", th.ID, got) |
| + } |
| + } |
| + got := render.ThemeID("a theme from a later build") |
| + if _, ok := render.ThemeVars(got); !ok { |
| + t.Errorf("ThemeID of an unknown theme is %q, which is not a theme either", got) |
| + } |
| +} |
| + |
| +// SlideVars is the seam the layout engine paints from: it has to give |
| +// the same colours Slide writes on the section, overrides and all. |
| +func TestSlideVarsAreWhatTheSectionCarries(t *testing.T) { |
| + d := fixture() |
| + for i := range d.Slides { |
| + vars, ok := render.SlideVars(d, i) |
| + if !ok { |
| + t.Fatalf("SlideVars(%d) reported unknown", i) |
| + } |
| + html, err := render.Slide(d, i, render.Desktop) |
| + if err != nil { |
| + t.Fatalf("Slide(%d): %v", i, err) |
| + } |
| + for _, k := range []string{"--slides-bg", "--slides-fg", "--slides-accent", "--slides-accent-2"} { |
| + if vars[k] == "" { |
| + t.Errorf("slide %d has no %s", i, k) |
| + } |
| + if want := k + ":" + vars[k]; !strings.Contains(string(html), want) { |
| + t.Errorf("slide %d: SlideVars says %q, the section does not carry it", i, want) |
| + } |
| + } |
| + } |
| + if _, ok := render.SlideVars(d, len(d.Slides)); ok { |
| + t.Error("SlideVars accepted an index outside the deck") |
| + } |
| + if _, ok := render.SlideVars(nil, 0); ok { |
| + t.Error("SlideVars accepted a nil deck") |
| + } |
| +} |
| + |
| +// Slide 3 of the fixture sets its own background colour, so its vars |
| +// are not its theme's — the case that makes SlideVars worth having. |
| +func TestSlideVarsHonourASlideBackground(t *testing.T) { |
| + d := fixture() |
| + vars, ok := render.SlideVars(d, 3) |
| + if !ok { |
| + t.Fatal("SlideVars(3) reported unknown") |
| + } |
| + if vars["--slides-bg"] != "#2b1030" { |
| + t.Errorf("--slides-bg is %q, want the slide's own #2b1030", vars["--slides-bg"]) |
| + } |
| + theme, _ := render.ThemeVars("paper") |
| + if vars["--slides-fg"] == theme["--slides-fg"] { |
| + t.Errorf("--slides-fg stayed %q on a plum background", vars["--slides-fg"]) |
| + } |
| +} |
diff --git a/internal/slides/render/testdata/blank-desktop.golden.html b/internal/slides/render/testdata/blank-desktop.golden.html| index d37ab62..9f2378c 100644 |
| --- a/internal/slides/render/testdata/blank-desktop.golden.html |
| +++ b/internal/slides/render/testdata/blank-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="6" data-id="slide007aaaa" data-layout="flow" data-template="blank" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="6" data-id="slide007aaaa" data-layout="flow" data-template="blank" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="body"> |
| <pre slides-block data-block="code" data-id="b061aaaaaaaa" data-size="m" data-language="go" data-region="body"><code class="language-go">func main() { |
| fmt.Println("hi <there>") |
diff --git a/internal/slides/render/testdata/blank-phone.golden.html b/internal/slides/render/testdata/blank-phone.golden.html| index dac9060..fc97007 100644 |
| --- a/internal/slides/render/testdata/blank-phone.golden.html |
| +++ b/internal/slides/render/testdata/blank-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="6" data-id="slide007aaaa" data-layout="flow" data-template="blank" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="6" data-id="slide007aaaa" data-layout="flow" data-template="blank" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="body"> |
| <pre slides-block data-block="code" data-id="b061aaaaaaaa" data-size="m" data-language="go" data-region="body"><code class="language-go">func main() { |
| fmt.Println("hi <there>") |
diff --git a/internal/slides/render/testdata/canvas-desktop.golden.html b/internal/slides/render/testdata/canvas-desktop.golden.html| index 96f0c09..9783a73 100644 |
| --- a/internal/slides/render/testdata/canvas-desktop.golden.html |
| +++ b/internal/slides/render/testdata/canvas-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="7" data-id="slide008aaaa" data-layout="canvas" data-template="blank" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="7" data-id="slide008aaaa" data-layout="canvas" data-template="blank" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <h1 slides-block data-block="heading" data-id="b081aaaaaaaa" data-level="1" data-z="3" style="left:6%;top:8%;width:88%;height:16%;z-index:3">Canvas: put it where you want it</h1> |
| <svg slides-block data-block="shape" data-id="b082aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-z="1" style="left:8%;top:34%;width:36%;height:44%;z-index:1"> |
| <rect x="1" y="1" width="98" height="98" rx="12" fill="#e8e2d6" stroke="#8a7f6a" stroke-width="2" vector-effect="non-scaling-stroke" /> |
diff --git a/internal/slides/render/testdata/canvas-phone.golden.html b/internal/slides/render/testdata/canvas-phone.golden.html| index faac93c..1e39714 100644 |
| --- a/internal/slides/render/testdata/canvas-phone.golden.html |
| +++ b/internal/slides/render/testdata/canvas-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="7" data-id="slide008aaaa" data-layout="canvas" data-template="blank" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="7" data-id="slide008aaaa" data-layout="canvas" data-template="blank" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <h1 slides-block data-block="heading" data-id="b081aaaaaaaa" data-level="1" data-z="3" style="left:6%;top:8%;width:88%;height:16%;z-index:3">Canvas: put it where you want it</h1> |
| <svg slides-block data-block="shape" data-id="b082aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" aria-hidden="true" data-z="1" style="left:8%;top:34%;width:36%;height:44%;z-index:1"> |
| <rect x="1" y="1" width="98" height="98" rx="12" fill="#e8e2d6" stroke="#8a7f6a" stroke-width="2" vector-effect="non-scaling-stroke" /> |
diff --git a/internal/slides/render/testdata/canvas-reflow-desktop.golden.html b/internal/slides/render/testdata/canvas-reflow-desktop.golden.html| index 4a3f593..8e381c7 100644 |
| --- a/internal/slides/render/testdata/canvas-reflow-desktop.golden.html |
| +++ b/internal/slides/render/testdata/canvas-reflow-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="8" data-id="slide009aaaa" data-layout="canvas" data-template="blank" data-theme="paper" data-transition="fade" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="8" data-id="slide009aaaa" data-layout="canvas" data-template="blank" data-theme="paper" data-transition="fade" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <h1 slides-block data-block="heading" data-id="b091aaaaaaaa" data-level="1" data-z="1" style="left:6%;top:10%;width:88%;height:16%;z-index:1">On a phone this one reflows</h1> |
| <svg slides-block data-block="shape" data-id="b092aaaaaaaa" data-kind="rect" viewBox="0 0 100 100" preserveAspectRatio="none" focusable="false" role="img" data-z="2" data-phone-hidden="true" style="left:64%;top:32%;width:30%;height:44%;z-index:2"> |
| <title>Desktop only</title> |
diff --git a/internal/slides/render/testdata/canvas-reflow-phone.golden.html b/internal/slides/render/testdata/canvas-reflow-phone.golden.html| index c62beaa..04f7b35 100644 |
| --- a/internal/slides/render/testdata/canvas-reflow-phone.golden.html |
| +++ b/internal/slides/render/testdata/canvas-reflow-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="8" data-id="slide009aaaa" data-layout="flow" data-template="blank" data-theme="paper" data-transition="fade" data-vp="phone" data-reflowed="canvas" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="8" data-id="slide009aaaa" data-layout="flow" data-template="blank" data-theme="paper" data-transition="fade" data-vp="phone" data-reflowed="canvas" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="body"> |
| <h1 slides-block data-block="heading" data-id="b091aaaaaaaa" data-level="1">On a phone this one reflows</h1> |
| <div slides-block data-block="text" data-id="b093aaaaaaaa" data-size="m"> |
diff --git a/internal/slides/render/testdata/image-full-desktop.golden.html b/internal/slides/render/testdata/image-full-desktop.golden.html| index 44f5100..ef1afa0 100644 |
| --- a/internal/slides/render/testdata/image-full-desktop.golden.html |
| +++ b/internal/slides/render/testdata/image-full-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="4" data-id="slide005aaaa" data-layout="flow" data-template="image-full" data-theme="paper" data-background="image" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif;--slides-bg-image:url(/assets/ee05ff06aa07bb08)"> |
| +<section slides-slide data-index="4" data-id="slide005aaaa" data-layout="flow" data-template="image-full" data-theme="paper" data-background="image" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;--slides-bg-image:url(/assets/ee05ff06aa07bb08)"> |
| <div slides-region data-region="heading"> |
| <h2 slides-block data-block="heading" data-id="b042aaaaaaaa" data-level="2" data-region="heading">A picture would go here</h2> |
| </div> |
diff --git a/internal/slides/render/testdata/image-full-phone.golden.html b/internal/slides/render/testdata/image-full-phone.golden.html| index ee42e93..dba3cfd 100644 |
| --- a/internal/slides/render/testdata/image-full-phone.golden.html |
| +++ b/internal/slides/render/testdata/image-full-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="4" data-id="slide005aaaa" data-layout="flow" data-template="image-full" data-theme="paper" data-background="image" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif;--slides-bg-image:url(/assets/ee05ff06aa07bb08)"> |
| +<section slides-slide data-index="4" data-id="slide005aaaa" data-layout="flow" data-template="image-full" data-theme="paper" data-background="image" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;--slides-bg-image:url(/assets/ee05ff06aa07bb08)"> |
| <div slides-region data-region="heading"> |
| <h2 slides-block data-block="heading" data-id="b042aaaaaaaa" data-level="2" data-region="heading">A picture would go here</h2> |
| </div> |
diff --git a/internal/slides/render/testdata/quote-desktop.golden.html b/internal/slides/render/testdata/quote-desktop.golden.html| index 6ff63d0..6695937 100644 |
| --- a/internal/slides/render/testdata/quote-desktop.golden.html |
| +++ b/internal/slides/render/testdata/quote-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="5" data-id="slide006aaaa" data-layout="flow" data-template="quote" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="5" data-id="slide006aaaa" data-layout="flow" data-template="quote" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="body"> |
| <figure slides-block data-block="quote" data-id="b051aaaaaaaa" data-region="body"> |
| <blockquote> |
diff --git a/internal/slides/render/testdata/quote-phone.golden.html b/internal/slides/render/testdata/quote-phone.golden.html| index fb70d47..0b2fc18 100644 |
| --- a/internal/slides/render/testdata/quote-phone.golden.html |
| +++ b/internal/slides/render/testdata/quote-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="5" data-id="slide006aaaa" data-layout="flow" data-template="quote" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="5" data-id="slide006aaaa" data-layout="flow" data-template="quote" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="body"> |
| <figure slides-block data-block="quote" data-id="b051aaaaaaaa" data-region="body"> |
| <blockquote> |
diff --git a/internal/slides/render/testdata/section-desktop.golden.html b/internal/slides/render/testdata/section-desktop.golden.html| index 50d96ab..2b4bccd 100644 |
| --- a/internal/slides/render/testdata/section-desktop.golden.html |
| +++ b/internal/slides/render/testdata/section-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="3" data-id="slide004aaaa" data-layout="flow" data-template="section" data-theme="paper" data-background="color" style="--slides-bg:#2b1030;--slides-fg:#faf7f0;--slides-accent:#faf7f0;--slides-accent-2:#faf7f0;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="3" data-id="slide004aaaa" data-layout="flow" data-template="section" data-theme="paper" data-background="color" style="--slides-bg:#2b1030;--slides-fg:#faf7f0;--slides-accent:#faf7f0;--slides-accent-2:#faf7f0;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h1 slides-block data-block="heading" data-id="b031aaaaaaaa" data-level="1" data-region="heading">Part two</h1> |
| </div> |
diff --git a/internal/slides/render/testdata/section-phone.golden.html b/internal/slides/render/testdata/section-phone.golden.html| index f46e53d..cdc6591 100644 |
| --- a/internal/slides/render/testdata/section-phone.golden.html |
| +++ b/internal/slides/render/testdata/section-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="3" data-id="slide004aaaa" data-layout="flow" data-template="section" data-theme="paper" data-background="color" data-vp="phone" style="--slides-bg:#2b1030;--slides-fg:#faf7f0;--slides-accent:#faf7f0;--slides-accent-2:#faf7f0;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="3" data-id="slide004aaaa" data-layout="flow" data-template="section" data-theme="paper" data-background="color" data-vp="phone" style="--slides-bg:#2b1030;--slides-fg:#faf7f0;--slides-accent:#faf7f0;--slides-accent-2:#faf7f0;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h1 slides-block data-block="heading" data-id="b031aaaaaaaa" data-level="1" data-region="heading">Part two</h1> |
| </div> |
diff --git a/internal/slides/render/testdata/title-body-desktop.golden.html b/internal/slides/render/testdata/title-body-desktop.golden.html| index 18ae349..3c0b27f 100644 |
| --- a/internal/slides/render/testdata/title-body-desktop.golden.html |
| +++ b/internal/slides/render/testdata/title-body-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="1" data-id="slide002aaaa" data-layout="flow" data-template="title-body" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="1" data-id="slide002aaaa" data-layout="flow" data-template="title-body" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h1 slides-block data-block="heading" data-id="b011aaaaaaaa" data-level="1" data-region="heading">Text and marks</h1> |
| </div> |
diff --git a/internal/slides/render/testdata/title-body-phone.golden.html b/internal/slides/render/testdata/title-body-phone.golden.html| index 7622cd2..5f6805a 100644 |
| --- a/internal/slides/render/testdata/title-body-phone.golden.html |
| +++ b/internal/slides/render/testdata/title-body-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="1" data-id="slide002aaaa" data-layout="flow" data-template="title-body" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="1" data-id="slide002aaaa" data-layout="flow" data-template="title-body" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h1 slides-block data-block="heading" data-id="b011aaaaaaaa" data-level="1" data-region="heading">Text and marks</h1> |
| </div> |
diff --git a/internal/slides/render/testdata/title-desktop.golden.html b/internal/slides/render/testdata/title-desktop.golden.html| index fbde455..081ae02 100644 |
| --- a/internal/slides/render/testdata/title-desktop.golden.html |
| +++ b/internal/slides/render/testdata/title-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="0" data-id="slide001aaaa" data-layout="flow" data-template="title" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="0" data-id="slide001aaaa" data-layout="flow" data-template="title" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h1 slides-block data-block="heading" data-id="b001aaaaaaaa" data-level="1" data-region="heading">Renderer fixture</h1> |
| </div> |
diff --git a/internal/slides/render/testdata/title-phone.golden.html b/internal/slides/render/testdata/title-phone.golden.html| index 3bb1ab0..7a7693c 100644 |
| --- a/internal/slides/render/testdata/title-phone.golden.html |
| +++ b/internal/slides/render/testdata/title-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="0" data-id="slide001aaaa" data-layout="flow" data-template="title" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="0" data-id="slide001aaaa" data-layout="flow" data-template="title" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h1 slides-block data-block="heading" data-id="b001aaaaaaaa" data-level="1" data-region="heading">Renderer fixture</h1> |
| </div> |
diff --git a/internal/slides/render/testdata/two-column-desktop.golden.html b/internal/slides/render/testdata/two-column-desktop.golden.html| index 5850314..20357d8 100644 |
| --- a/internal/slides/render/testdata/two-column-desktop.golden.html |
| +++ b/internal/slides/render/testdata/two-column-desktop.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="2" data-id="slide003aaaa" data-layout="flow" data-template="two-column" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="2" data-id="slide003aaaa" data-layout="flow" data-template="two-column" data-theme="paper" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h2 slides-block data-block="heading" data-id="b021aaaaaaaa" data-level="2" data-region="heading">Regions, not coordinates</h2> |
| </div> |
diff --git a/internal/slides/render/testdata/two-column-phone.golden.html b/internal/slides/render/testdata/two-column-phone.golden.html| index b33d284..a021078 100644 |
| --- a/internal/slides/render/testdata/two-column-phone.golden.html |
| +++ b/internal/slides/render/testdata/two-column-phone.golden.html |
| @@ -1,4 +1,4 @@ |
| -<section slides-slide data-index="2" data-id="slide003aaaa" data-layout="flow" data-template="two-column" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Iowan Old Style, Palatino Linotype, Georgia, serif;--slides-font-body:system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| +<section slides-slide data-index="2" data-id="slide003aaaa" data-layout="flow" data-template="two-column" data-theme="paper" data-vp="phone" style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"> |
| <div slides-region data-region="heading"> |
| <h2 slides-block data-block="heading" data-id="b021aaaaaaaa" data-level="2" data-region="heading">Regions, not coordinates</h2> |
| </div> |
diff --git a/internal/slides/render/themes.go b/internal/slides/render/themes.go| index e40208a..351fc2d 100644 |
| --- a/internal/slides/render/themes.go |
| +++ b/internal/slides/render/themes.go |
| @@ -3,6 +3,8 @@ package render |
| import ( |
| "math" |
| "strconv" |
| + |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| ) |
| // themes.go is the six looks a deck can wear (spec §2.1, the picker in |
| @@ -38,9 +40,21 @@ import ( |
| // section rule, a two-colour chart and a link-versus-highlight pair |
| // somewhere to come from without inventing a colour at the call site. |
| -// serifStack is `paper`'s heading face: an old-style serif where one is |
| -// installed, Georgia everywhere else, and never a download — the CSS |
| -// fetches nothing (rastrillo's SKILL.md, §"Modern CSS is the floor"). |
| +// serifStack is `paper`'s heading face and systemStack is every other |
| +// theme's heading face and every theme's body face. Both name a face |
| +// this app serves itself (internal/slides/fonts, declared in |
| +// slides.css) ahead of the machine's own, and that order is the point: |
| +// the Go layout engine measures a slide with those exact font programs |
| +// to make the SVG and the PDF, so a browser setting the same slide in |
| +// whatever it calls `system-ui` would break its lines somewhere else. |
| +// The fallbacks are what a reader sees for the moment before the |
| +// download lands, and they are chosen to be near enough that the |
| +// reflow is not a jolt. |
| +// |
| +// They come from fonts rather than being spelled here so that one |
| +// place decides what a family is called; fonts.FamilyForStack reads |
| +// them back, which is how the layout engine turns a theme's CSS |
| +// property into a face to measure with. |
| // |
| // Unquoted, because these stacks are written into a style attribute and |
| // CSS takes a family name as a sequence of identifiers: quoting would |
| @@ -48,11 +62,9 @@ import ( |
| // family whose name needs quotes (one starting with a digit, say) would |
| // have to be quoted, and safeFontStack allows that for the deck-level |
| // override. |
| -const serifStack = "Iowan Old Style, Palatino Linotype, Georgia, serif" |
| +var serifStack = fonts.Serif.Stack() |
| -// systemStack is every other theme's heading face and every theme's |
| -// body face: whatever the machine calls its own interface font. |
| -const systemStack = "system-ui, -apple-system, Segoe UI, Roboto, sans-serif" |
| +var systemStack = fonts.Sans.Stack() |
| // Theme is one entry in the picker: the id stored on the deck and the |
| // English name. The app's own picker labels themes from the catalog |
diff --git a/internal/slides/static/fonts/LICENSE-OFL-1.1.txt b/internal/slides/static/fonts/LICENSE-OFL-1.1.txt| new file mode 100644 |
| index 0000000..84619fc |
| --- /dev/null |
| +++ b/internal/slides/static/fonts/LICENSE-OFL-1.1.txt |
| @@ -0,0 +1,91 @@ |
| +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 |
| + |
| +PREAMBLE The goals of the Open Font License (OFL) are to stimulate |
| +worldwide development of collaborative font projects, to support the font |
| +creation efforts of academic and linguistic communities, and to provide |
| +a free and open framework in which fonts may be shared and improved in |
| +partnership with others. |
| + |
| +The OFL allows the licensed fonts to be used, studied, modified and |
| +redistributed freely as long as they are not sold by themselves. |
| +The fonts, including any derivative works, can be bundled, embedded, |
| +redistributed and/or sold with any software provided that any reserved |
| +names are not used by derivative works. The fonts and derivatives, |
| +however, cannot be released under any other type of license. The |
| +requirement for fonts to remain under this license does not apply to |
| +any document created using the fonts or their derivatives. |
| + |
| + |
| + |
| +DEFINITIONS |
| +"Font Software" refers to the set of files released by the Copyright |
| +Holder(s) under this license and clearly marked as such. |
| +This may include source files, build scripts and documentation. |
| + |
| +"Reserved Font Name" refers to any names specified as such after the |
| +copyright statement(s). |
| + |
| +"Original Version" refers to the collection of Font Software components |
| +as distributed by the Copyright Holder(s). |
| + |
| +"Modified Version" refers to any derivative made by adding to, deleting, |
| +or substituting ? in part or in whole ? |
| +any of the components of the Original Version, by changing formats or |
| +by porting the Font Software to a new environment. |
| + |
| +"Author" refers to any designer, engineer, programmer, technical writer |
| +or other person who contributed to the Font Software. |
| + |
| + |
| +PERMISSION & CONDITIONS |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a |
| +copy of the Font Software, to use, study, copy, merge, embed, modify, |
| +redistribute, and sell modified and unmodified copies of the Font |
| +Software, subject to the following conditions: |
| + |
| +1) Neither the Font Software nor any of its individual components,in |
| + Original or Modified Versions, may be sold by itself. |
| + |
| +2) Original or Modified Versions of the Font Software may be bundled, |
| + redistributed and/or sold with any software, provided that each copy |
| + contains the above copyright notice and this license. These can be |
| + included either as stand-alone text files, human-readable headers or |
| + in the appropriate machine-readable metadata fields within text or |
| + binary files as long as those fields can be easily viewed by the user. |
| + |
| +3) No Modified Version of the Font Software may use the Reserved Font |
| + Name(s) unless explicit written permission is granted by the |
| + corresponding Copyright Holder. This restriction only applies to the |
| + primary font name as presented to the users. |
| + |
| +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font |
| + Software shall not be used to promote, endorse or advertise any |
| + Modified Version, except to acknowledge the contribution(s) of the |
| + Copyright Holder(s) and the Author(s) or with their explicit written |
| + permission. |
| + |
| +5) The Font Software, modified or unmodified, in part or in whole, must |
| + be distributed entirely under this license, and must not be distributed |
| + under any other license. The requirement for fonts to remain under |
| + this license does not apply to any document created using the Font |
| + Software. |
| + |
| + |
| + |
| +TERMINATION |
| +This license becomes null and void if any of the above conditions are not met. |
| + |
| + |
| + |
| +DISCLAIMER |
| +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
| +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF |
| +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT |
| +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE |
| +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
| +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL |
| +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
| +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER |
| +DEALINGS IN THE FONT SOFTWARE. |
| + |
diff --git a/internal/slides/static/fonts/LiberationMono-Bold.ttf b/internal/slides/static/fonts/LiberationMono-Bold.ttf| new file mode 100644 |
| index 0000000..c490006 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationMono-Bold.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationMono-BoldItalic.ttf b/internal/slides/static/fonts/LiberationMono-BoldItalic.ttf| new file mode 100644 |
| index 0000000..f107ec6 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationMono-BoldItalic.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationMono-Italic.ttf b/internal/slides/static/fonts/LiberationMono-Italic.ttf| new file mode 100644 |
| index 0000000..c7fa413 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationMono-Italic.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationMono-Regular.ttf b/internal/slides/static/fonts/LiberationMono-Regular.ttf| new file mode 100644 |
| index 0000000..fa81eaa |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationMono-Regular.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSans-Bold.ttf b/internal/slides/static/fonts/LiberationSans-Bold.ttf| new file mode 100644 |
| index 0000000..31ddaef |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSans-Bold.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSans-BoldItalic.ttf b/internal/slides/static/fonts/LiberationSans-BoldItalic.ttf| new file mode 100644 |
| index 0000000..153950d |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSans-BoldItalic.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSans-Italic.ttf b/internal/slides/static/fonts/LiberationSans-Italic.ttf| new file mode 100644 |
| index 0000000..ebf48bc |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSans-Italic.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSans-Regular.ttf b/internal/slides/static/fonts/LiberationSans-Regular.ttf| new file mode 100644 |
| index 0000000..f524339 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSans-Regular.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSerif-Bold.ttf b/internal/slides/static/fonts/LiberationSerif-Bold.ttf| new file mode 100644 |
| index 0000000..8dce980 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSerif-Bold.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSerif-BoldItalic.ttf b/internal/slides/static/fonts/LiberationSerif-BoldItalic.ttf| new file mode 100644 |
| index 0000000..04c27fd |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSerif-BoldItalic.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSerif-Italic.ttf b/internal/slides/static/fonts/LiberationSerif-Italic.ttf| new file mode 100644 |
| index 0000000..01b9210 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSerif-Italic.ttf differ |
diff --git a/internal/slides/static/fonts/LiberationSerif-Regular.ttf b/internal/slides/static/fonts/LiberationSerif-Regular.ttf| new file mode 100644 |
| index 0000000..d7622a3 |
| Binary files /dev/null and b/internal/slides/static/fonts/LiberationSerif-Regular.ttf differ |
diff --git a/internal/slides/static/slides.css b/internal/slides/static/slides.css| index 05da017..a4b65ef 100644 |
| --- a/internal/slides/static/slides.css |
| +++ b/internal/slides/static/slides.css |
| @@ -50,8 +50,123 @@ |
| 320px reflow is not optional. |
| Modern CSS is the floor (rastrillo SKILL.md): container queries, |
| - `oklch()`, `color-mix()` and `light-dark()` are all free here. The |
| - file fetches nothing — no import, no webfont, no remote image. */ |
| + `oklch()`, `color-mix()` and `light-dark()` are all free here. |
| + |
| + ── The fonts ──────────────────────────────────────────────────────── |
| + |
| + The one thing this file does fetch: the twelve Liberation faces the |
| + app serves itself, declared below. They are not decoration. The Go |
| + layout engine (internal/slides/layout) measures a slide with these |
| + exact font programs to produce the SVG thumbnail and the PDF, so a |
| + browser laying the same slide out with whatever the machine calls |
| + `system-ui` would break its lines somewhere else — and a deck that |
| + paginates one way on screen and another way in its own export is two |
| + decks. internal/slides/fonts holds the same twelve files for the |
| + engine's side of that promise. |
| + |
| + Each URL carries the file's content hash so the asset handler serves |
| + it immutable (rastrillo's Assets: a hashed name that matches the |
| + current content is cacheable forever). A hash that has gone stale is |
| + not a 404 — the handler strips it, finds the file and serves it |
| + no-cache — so the failure mode is a slower fetch, not a missing |
| + font. internal/slidestest's TestSlidesCSSFontURLsCarryTheirContentHash |
| + is what keeps them current. */ |
| + |
| +/* ── @font-face ────────────────────────────────────────────────────── |
| + Liberation Serif, Sans and Mono, SIL OFL 1.1 — the licence ships |
| + beside them at /static/fonts/LICENSE-OFL-1.1.txt, as it requires. |
| + TrueType rather than woff2 (about twice the bytes) because |
| + converting needs a build step this repo's committed-artifact static |
| + tree does not have; a browser fetches only the faces a page uses. |
| + `swap`, because a slide with invisible text is worse for three |
| + seconds than a slide that reflows once. */ |
| + |
| +@font-face { |
| + font-family: "Liberation Sans"; |
| + font-style: normal; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("fonts/LiberationSans-Regular.bade59d822652f76.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Sans"; |
| + font-style: normal; |
| + font-weight: 700; |
| + font-display: swap; |
| + src: url("fonts/LiberationSans-Bold.1b5f2da6f4cadce4.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Sans"; |
| + font-style: italic; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("fonts/LiberationSans-Italic.93ff5260834141b6.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Sans"; |
| + font-style: italic; |
| + font-weight: 700; |
| + font-display: swap; |
| + src: url("fonts/LiberationSans-BoldItalic.352e9dc2be89f210.ttf") format("truetype"); |
| +} |
| + |
| +@font-face { |
| + font-family: "Liberation Serif"; |
| + font-style: normal; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("fonts/LiberationSerif-Regular.9caef765d2e891c1.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Serif"; |
| + font-style: normal; |
| + font-weight: 700; |
| + font-display: swap; |
| + src: url("fonts/LiberationSerif-Bold.443a4dbd694b6e46.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Serif"; |
| + font-style: italic; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("fonts/LiberationSerif-Italic.de18fe26337e0324.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Serif"; |
| + font-style: italic; |
| + font-weight: 700; |
| + font-display: swap; |
| + src: url("fonts/LiberationSerif-BoldItalic.4b2635d2ec4d9923.ttf") format("truetype"); |
| +} |
| + |
| +@font-face { |
| + font-family: "Liberation Mono"; |
| + font-style: normal; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("fonts/LiberationMono-Regular.5883330d94debd99.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Mono"; |
| + font-style: normal; |
| + font-weight: 700; |
| + font-display: swap; |
| + src: url("fonts/LiberationMono-Bold.ddce09ee3483b2d5.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Mono"; |
| + font-style: italic; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("fonts/LiberationMono-Italic.016743fc425a1b61.ttf") format("truetype"); |
| +} |
| +@font-face { |
| + font-family: "Liberation Mono"; |
| + font-style: italic; |
| + font-weight: 700; |
| + font-display: swap; |
| + src: url("fonts/LiberationMono-BoldItalic.2ec6f3e5bd9b24c7.ttf") format("truetype"); |
| +} |
| [slides-slide] { |
| /* The palette. These values are what a section renders as when it |
| @@ -62,8 +177,11 @@ |
| --slides-fg: #1c1a17; |
| --slides-accent: #8a5410; |
| --slides-accent-2: #46617a; |
| - --slides-font-heading: Iowan Old Style, Palatino Linotype, Georgia, serif; |
| - --slides-font-body: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; |
| + --slides-font-heading: Liberation Serif, Georgia, serif; |
| + --slides-font-body: Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; |
| + /* Code is one face on every theme, so it is a variable here and |
| + not a theme property. */ |
| + --slides-font-code: Liberation Mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| /* The type scale, in cqw so it is a fraction of the slide's own |
| width. Calibrated on a 1920-wide frame: 6.6cqw is ~127px, about a |
| @@ -377,7 +495,7 @@ |
| [slides-block][data-block="code"] { |
| font-size: var(--slides-code); |
| - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| + font-family: var(--slides-font-code); |
| line-height: 1.45; |
| padding: 1em; |
| border-radius: 0.6em; |
| @@ -392,7 +510,7 @@ |
| font: inherit; |
| } |
| [slides-block] :not(pre) > code { |
| - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| + font-family: var(--slides-font-code); |
| font-size: 0.88em; |
| padding: 0.1em 0.3em; |
| border-radius: 0.25em; |
diff --git a/internal/slidestest/browser_layout_test.go b/internal/slidestest/browser_layout_test.go| new file mode 100644 |
| index 0000000..7a317f2 |
| --- /dev/null |
| +++ b/internal/slidestest/browser_layout_test.go |
| @@ -0,0 +1,197 @@ |
| +//go:build browser |
| + |
| +// The layout engine's own drive. internal/slides/layout measures a |
| +// slide the way slides.css would at 1920 by 1080 — it is a reading of |
| +// that stylesheet, done in Go, and everything downstream of it (the |
| +// SVG thumbnail, the PDF) inherits whatever that reading gets wrong. |
| +// |
| +// Nothing in the Go tests can catch a misreading: they check the engine |
| +// against itself. This file is the other half — a real Chromium laying |
| +// out real slide markup at 1920, with the numbers sizes.go and |
| +// regions.go were derived from asserted against what the browser |
| +// actually does. |
| +// |
| +// It caught two mistakes when it was written (slice 3 task 2), and |
| +// they are the reason it exists rather than a comment saying the |
| +// numbers looked right: |
| +// |
| +// - A container query unit is a percent of the container's CONTENT |
| +// box, so a `cqw` on a slide with the section's 120px padding is |
| +// 16.8px and not 19.2. Every type size on six of the seven |
| +// templates was 12.5% too big. |
| +// - A picture or a shape in a flow region is a flex item that |
| +// SHRINKS to the space that is left; the engine was stacking a |
| +// square 1680px picture and running three slides off the bottom. |
| +package slidestest |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/chromedp/chromedp" |
| +) |
| + |
| +// wantGeometry is what slides.css computes at 1920, and every number |
| +// in it has a twin in internal/slides/layout (sizes.go's scale, and |
| +// regions.go's table). When this fails, one of the two has moved: read |
| +// the diff before changing either. |
| +// |
| +// The tolerance is a pixel. Chromium rounds a used font size to a |
| +// 64th, so 121.968 comes back as 121.95, and pinning tighter than that |
| +// would fail on a browser upgrade without anything being wrong. |
| +const tolerance = 1.0 |
| + |
| +type rect struct{ X, Y, W, H float64 } |
| + |
| +type geometry struct { |
| + TwoColumnHeading rect `json:"twoColumnHeading"` |
| + TwoColumnBody rect `json:"twoColumnBody"` |
| + TwoColumnAside rect `json:"twoColumnAside"` |
| + TitleHeading rect `json:"titleHeading"` |
| + TitleBody rect `json:"titleBody"` |
| + ImageFullMedia rect `json:"imageFullMedia"` |
| + BlankRegion rect `json:"blankRegion"` |
| + BlankText rect `json:"blankText"` |
| + BlankImage rect `json:"blankImage"` |
| + PaddedT1 float64 `json:"paddedT1"` |
| + PaddedBodyM float64 `json:"paddedBodyM"` |
| + PaddedBodyL float64 `json:"paddedBodyL"` |
| + FullT1 float64 `json:"fullT1"` |
| + FullT2 float64 `json:"fullT2"` |
| + HeadingLine float64 `json:"headingLine"` |
| + BodyLine float64 `json:"bodyLine"` |
| +} |
| + |
| +func TestTheStylesheetMeasuresWhatTheLayoutEngineAssumes(t *testing.T) { |
| + rig, _ := newRig(t) |
| + |
| + const vars = `style="--slides-bg:#faf7f0;--slides-fg:#1c1a17;--slides-accent:#8a5410;--slides-accent-2:#46617a;--slides-font-heading:Liberation Serif, Georgia, serif;--slides-font-body:Liberation Sans, system-ui, sans-serif"` |
| + // One section per template under test, at the frame's own width. |
| + // Built by hand rather than through render.Slide because the point |
| + // is the STYLESHEET: this is the markup render produces, and a |
| + // change in either that broke the pairing should fail here. |
| + const markup = ` |
| +<div style="width:1920px;position:absolute;left:0;top:0"> |
| +<section slides-slide data-layout="flow" data-template="two-column" data-theme="paper" ` + vars + `> |
| +<div slides-region data-region="heading" id="TCH"><h1 slides-block data-block="heading" data-level="1" id="TCH1">Regions, not coordinates</h1></div> |
| +<div slides-region data-region="body" id="TCB"><div slides-block data-block="text" data-size="m" id="TCT"><p>A flow slide names regions.</p></div></div> |
| +<div slides-region data-region="aside" id="TCA"><div slides-block data-block="text" data-size="m"><p>The aside.</p></div></div> |
| +</section> |
| +<section slides-slide data-layout="flow" data-template="image-full" data-theme="paper" ` + vars + `> |
| +<div slides-region data-region="heading"><h2 slides-block data-block="heading" data-level="2" id="IFH2">A picture would go here</h2></div> |
| +<div slides-region data-region="media" id="IFM"><figure slides-block data-block="image" data-fit="cover"><img src="/static/fonts/nothing.png" alt="x" width="1600" height="900"></figure></div> |
| +</section> |
| +<section slides-slide data-layout="canvas" data-template="blank" data-theme="paper" ` + vars + `> |
| +<h1 slides-block data-block="heading" data-level="1" data-z="1" id="CVH" style="left:6%;top:8%;width:88%;height:16%;z-index:1">Canvas</h1> |
| +</section> |
| +<section slides-slide data-layout="flow" data-template="title" data-theme="paper" ` + vars + `> |
| +<div slides-region data-region="heading" id="TH"><h1 slides-block data-block="heading" data-level="1">Renderer fixture</h1></div> |
| +<div slides-region data-region="body" id="TB"><div slides-block data-block="text" data-size="l" id="TT"><p>One section per slide, one element per block.</p></div></div> |
| +</section> |
| +<section slides-slide data-layout="flow" data-template="blank" data-theme="paper" ` + vars + `> |
| +<div slides-region data-region="body" id="BR"> |
| +<div slides-block data-block="text" data-size="m" id="BT"><p>One line.</p></div> |
| +<figure slides-block data-block="image" data-fit="contain" id="BI"><img src="/static/fonts/nothing.png" alt="x" width="64" height="64"></figure> |
| +</div></section> |
| +</div>` |
| + |
| + var raw string |
| + rig.Run( |
| + // The frame's own size, because `[slides-slide] { gap }` is a |
| + // cqw length written on the query container itself and so |
| + // resolves against whatever is OUTSIDE the section — the |
| + // viewport. sizes.go's sectionGap is this case, a slide |
| + // filling a 1920 screen, which is what an export is. |
| + chromedp.EmulateViewport(1920, 1080), |
| + chromedp.Navigate(rig.Origin+"/signin"), |
| + chromedp.Evaluate(`(() => { |
| + const link = document.createElement('link'); |
| + link.rel = 'stylesheet'; |
| + link.href = '/static/slides.css'; |
| + document.head.appendChild(link); |
| + document.body.insertAdjacentHTML('beforeend', `+"`"+markup+"`"+`); |
| + return 'ok'; |
| + })()`, new(string)), |
| + // Long enough for the stylesheet and the faces it asks for. |
| + chromedp.Sleep(2_000_000_000), |
| + chromedp.Evaluate(`(() => { |
| + const q = id => { const e = document.getElementById(id); const r = e.getBoundingClientRect(); |
| + const p = e.closest('[slides-slide]').getBoundingClientRect(); |
| + return {X: r.x-p.x, Y: r.y-p.y, W: r.width, H: r.height}; }; |
| + const px = (id, prop) => parseFloat(getComputedStyle(document.getElementById(id))[prop]); |
| + return JSON.stringify({ |
| + twoColumnHeading: q('TCH'), twoColumnBody: q('TCB'), twoColumnAside: q('TCA'), |
| + titleHeading: q('TH'), titleBody: q('TB'), |
| + imageFullMedia: q('IFM'), |
| + blankRegion: q('BR'), blankText: q('BT'), blankImage: q('BI'), |
| + paddedT1: px('TCH1','fontSize'), paddedBodyM: px('TCT','fontSize'), paddedBodyL: px('TT','fontSize'), |
| + fullT1: px('CVH','fontSize'), fullT2: px('IFH2','fontSize'), |
| + headingLine: px('TCH1','lineHeight'), bodyLine: px('TCT','lineHeight'), |
| + }); |
| + })()`, &raw), |
| + ) |
| + |
| + var got geometry |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("read the browser's geometry: %v\n%s", err, raw) |
| + } |
| + |
| + // The type scale. 16.8 per cqw with the section's padding, 19.2 |
| + // without it (image-full and canvas both set `padding: 0`). |
| + for _, c := range []struct { |
| + name string |
| + got, want float64 |
| + derivation string |
| + }{ |
| + {"--slides-t-1 on a padded template", got.PaddedT1, 110.88, "6.6 × 16.8"}, |
| + {"--slides-body-m on a padded template", got.PaddedBodyM, 47.04, "2.8 × 16.8"}, |
| + {"--slides-body-l on a padded template", got.PaddedBodyL, 57.12, "3.4 × 16.8"}, |
| + {"--slides-t-1 on a canvas slide", got.FullT1, 126.72, "6.6 × 19.2"}, |
| + {"--slides-t-2 on image-full", got.FullT2, 96, "5 × 19.2"}, |
| + {"a heading's line box", got.HeadingLine, 121.968, "110.88 × 1.1"}, |
| + {"a body line box", got.BodyLine, 61.152, "47.04 × 1.3"}, |
| + } { |
| + if diff := c.got - c.want; diff > tolerance || diff < -tolerance { |
| + t.Errorf("%s is %v in the browser; layout/sizes.go says %v (%s)", |
| + c.name, c.got, c.want, c.derivation) |
| + } |
| + } |
| + |
| + // The region boxes, which are layout/regions.go's table. |
| + for _, c := range []struct { |
| + name string |
| + got rect |
| + want rect |
| + }{ |
| + {"two-column heading", got.TwoColumnHeading, rect{120, 120, 1680, 121.968}}, |
| + {"two-column body", got.TwoColumnBody, rect{120, 291.888, 800.064, 668.112}}, |
| + {"two-column aside", got.TwoColumnAside, rect{999.936, 291.888, 800.064, 668.112}}, |
| + // title centres its two auto rows as a pair, so where each one |
| + // lands depends on how tall the other is. |
| + {"title heading", got.TitleHeading, rect{573.19, 416.928, 773.61, 121.968}}, |
| + {"title body", got.TitleBody, rect{386.98, 588.816, 1146.02, 74.256}}, |
| + {"image-full media", got.ImageFullMedia, rect{0, 0, 1920, 1080}}, |
| + {"a blank slide's body region", got.BlankRegion, rect{120, 120, 1680, 840}}, |
| + } { |
| + compareRect(t, c.name, c.got, c.want) |
| + } |
| + |
| + // A picture in a flow region is a flex item and shrinks to what is |
| + // left: 840 less the text above it and the gap between them. |
| + compareRect(t, "a picture under one line of text", |
| + got.BlankImage, rect{120, 224.832, 1680, 735.168}) |
| + compareRect(t, "that line of text", got.BlankText, rect{120, 120, 1680, 61.152}) |
| +} |
| + |
| +func compareRect(t *testing.T, name string, got, want rect) { |
| + t.Helper() |
| + for _, c := range []struct { |
| + axis string |
| + got, want float64 |
| + }{{"x", got.X, want.X}, {"y", got.Y, want.Y}, {"width", got.W, want.W}, {"height", got.H, want.H}} { |
| + if diff := c.got - c.want; diff > tolerance || diff < -tolerance { |
| + t.Errorf("%s: the browser puts its %s at %v; internal/slides/layout says %v", |
| + name, c.axis, c.got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/slidestest/fonts_test.go b/internal/slidestest/fonts_test.go| new file mode 100644 |
| index 0000000..964946b |
| --- /dev/null |
| +++ b/internal/slidestest/fonts_test.go |
| @@ -0,0 +1,185 @@ |
| +package slidestest |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "fmt" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/slides/internal/slides/fonts" |
| +) |
| + |
| +// This file holds slides.css's half of "one font set everywhere" to |
| +// the Go half. The stylesheet is a static file — nothing templates it |
| +// — so the twelve @font-face URLs, the fallback stacks and the served |
| +// bytes are all things that can drift silently. These tests are what |
| +// notice. |
| + |
| +func slidesCSS(t *testing.T) string { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join("..", "slides", "static", "slides.css")) |
| + if err != nil { |
| + t.Fatalf("read slides.css: %v", err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// fontFaceSrc pulls each @font-face's family, style, weight and URL |
| +// out in the order slides.css writes them. |
| +var fontFaceRule = regexp.MustCompile(`(?s)@font-face \{(.*?)\}`) |
| + |
| +type faceRule struct { |
| + family, style, weight, url string |
| +} |
| + |
| +func parseFontFaces(t *testing.T, css string) []faceRule { |
| + t.Helper() |
| + field := func(body, name string) string { |
| + re := regexp.MustCompile(name + `:\s*([^;]+);`) |
| + m := re.FindStringSubmatch(body) |
| + if m == nil { |
| + return "" |
| + } |
| + return strings.Trim(strings.TrimSpace(m[1]), `"`) |
| + } |
| + var out []faceRule |
| + for _, m := range fontFaceRule.FindAllStringSubmatch(css, -1) { |
| + body := m[1] |
| + src := field(body, "src") |
| + u := regexp.MustCompile(`url\("([^"]+)"\)`).FindStringSubmatch(src) |
| + if u == nil { |
| + t.Errorf("an @font-face has no url(): %q", src) |
| + continue |
| + } |
| + out = append(out, faceRule{ |
| + family: field(body, "font-family"), |
| + style: field(body, "font-style"), |
| + weight: field(body, "font-weight"), |
| + url: u[1], |
| + }) |
| + } |
| + return out |
| +} |
| + |
| +// Every one of the twelve embedded faces is declared, once, under its |
| +// own family name and with the weight and slope that select it. A face |
| +// the engine measures with and the browser cannot fetch is the |
| +// divergence this whole font set exists to close. |
| +func TestSlidesCSSDeclaresEveryEmbeddedFace(t *testing.T) { |
| + rules := parseFontFaces(t, slidesCSS(t)) |
| + got := map[string]string{} |
| + for _, r := range rules { |
| + key := fmt.Sprintf("%s|%s|%s", r.family, r.style, r.weight) |
| + if prev, dup := got[key]; dup { |
| + t.Errorf("%s is declared twice (%s and %s)", key, prev, r.url) |
| + } |
| + got[key] = r.url |
| + } |
| + for _, f := range fonts.All() { |
| + style, weight := "normal", "400" |
| + if f.Italic { |
| + style = "italic" |
| + } |
| + if f.Bold { |
| + weight = "700" |
| + } |
| + key := fmt.Sprintf("%s|%s|%s", f.Family.CSSName(), style, weight) |
| + url, ok := got[key] |
| + if !ok { |
| + t.Errorf("slides.css declares no @font-face for %s (%s)", f.File(), key) |
| + continue |
| + } |
| + if !strings.HasPrefix(url, "fonts/"+strings.TrimSuffix(f.File(), ".ttf")+".") { |
| + t.Errorf("%s is declared as %q, which is not that face's file", key, url) |
| + } |
| + delete(got, key) |
| + } |
| + for key, url := range got { |
| + t.Errorf("slides.css declares %s (%s), which is not a face this build embeds", key, url) |
| + } |
| +} |
| + |
| +// The URLs carry a content hash so rastrillo's asset handler serves |
| +// them immutable rather than no-cache — a slide is a page load, and |
| +// refetching a megabyte of fonts on every next-slide link is not a |
| +// cost worth paying for a set of files that never change. |
| +// |
| +// A stale hash is not a broken page (the handler strips it, finds the |
| +// file and serves it no-cache), so this failing means "slower", not |
| +// "broken" — the message says what to write instead. |
| +func TestSlidesCSSFontURLsCarryTheirContentHash(t *testing.T) { |
| + for _, r := range parseFontFaces(t, slidesCSS(t)) { |
| + name := strings.TrimPrefix(r.url, "fonts/") |
| + parts := strings.Split(name, ".") |
| + if len(parts) != 3 || parts[2] != "ttf" || len(parts[1]) != 16 { |
| + t.Errorf("%q is not <face>.<16 hex>.ttf", r.url) |
| + continue |
| + } |
| + b, err := os.ReadFile(filepath.Join("..", "slides", "static", "fonts", parts[0]+".ttf")) |
| + if err != nil { |
| + t.Errorf("%s: %v", r.url, err) |
| + continue |
| + } |
| + sum := sha256.Sum256(b) |
| + if want := hex.EncodeToString(sum[:])[:16]; parts[1] != want { |
| + t.Errorf("%s carries a stale hash; write fonts/%s.%s.ttf", r.url, parts[0], want) |
| + } |
| + } |
| +} |
| + |
| +// The stylesheet's own fallback stacks — what a section that arrived |
| +// without inline custom properties renders as — name the same faces |
| +// the themes do. |
| +func TestSlidesCSSFallbackStacksAreTheEmbeddedFamilies(t *testing.T) { |
| + css := slidesCSS(t) |
| + for _, c := range []struct { |
| + prop string |
| + fam fonts.Family |
| + }{ |
| + {"--slides-font-heading", fonts.Serif}, |
| + {"--slides-font-body", fonts.Sans}, |
| + {"--slides-font-code", fonts.Mono}, |
| + } { |
| + want := c.prop + ": " + c.fam.Stack() + ";" |
| + if !strings.Contains(css, want) { |
| + t.Errorf("slides.css does not set %q", want) |
| + } |
| + } |
| +} |
| + |
| +// The end of the wire: the app serves the bytes at the URL the |
| +// stylesheet asks for, with the immutable Cache-Control the hash buys. |
| +func TestTheAppServesTheDeclaredFontFiles(t *testing.T) { |
| + a := newApp(t) |
| + for _, r := range parseFontFaces(t, slidesCSS(t)) { |
| + rec := get(t, a, "/static/"+r.url) |
| + if rec.Code != http.StatusOK { |
| + t.Errorf("GET /static/%s: %d", r.url, rec.Code) |
| + continue |
| + } |
| + if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") { |
| + t.Errorf("GET /static/%s: Cache-Control %q, want an immutable one", r.url, got) |
| + } |
| + if rec.Body.Len() < 100_000 { |
| + t.Errorf("GET /static/%s returned %d bytes", r.url, rec.Body.Len()) |
| + } |
| + } |
| +} |
| + |
| +// The licence travels with the fonts, as the OFL requires of anything |
| +// redistributing them — and a slide surface is redistribution. |
| +func TestTheAppServesTheFontLicence(t *testing.T) { |
| + a := newApp(t) |
| + rec := get(t, a, "/static/fonts/LICENSE-OFL-1.1.txt") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET the font licence: %d", rec.Code) |
| + } |
| + if !strings.Contains(rec.Body.String(), "SIL OPEN FONT LICENSE") { |
| + t.Error("the served licence is not the OFL") |
| + } |
| +} |