Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/emit/svg.rs

18.9 KiB, 78 runs

created by r1870400018:35663, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The SVG page writer.
2//!
3//! The geometry and paint are handed to `fe2o3_graphics`: [`Path::rect`] builds each box, a glyph's
4//! outline arrives from `fe2o3_font` as a [`Path`] and is placed with [`Path::transform`], and the
5//! crate's own [`write_path_data`] and [`presentation`] render the `d` attribute and the fill or
6//! stroke. This module writes only the element tree around them -- the `<svg>`, `<rect>` and
7//! `<path>` -- which `fe2o3_graphics::svg` deliberately leaves to the caller, because the document
8//! shape above a `<path>` is the caller's format, not that crate's.
9
10use crate::font::ShapedText;
11use crate::ir::{
12 DrawOp,
13 Graphic,
14 Sp,
15};
16use crate::memo::{
17 Fnv,
18 Memo,
19};
20use crate::page::{
21 Page,
22 Placed,
23 PlacedKind,
24};
25
26use oxedyne_fe2o3_core::prelude::*;
27use oxedyne_fe2o3_graphics::{
28 colour::Rgba,
29 path::{
30 Bounds,
31 Path,
32 },
33 pixmap::Pixmap,
34 stroke::Stroke,
35 svg::{
36 presentation,
37 write_path_data,
38 },
39 transform::Transform,
40};
41use oxedyne_fe2o3_text::base64;
42use oxedyne_fe2o3_text::xml::write::escape as xml_escape;
43
44/// Renders one page as a self-contained SVG document. This is the whole-frame path, unchanged in its
45/// bytes: it renders every placed item -- body and furniture alike -- as one slice.
46pub fn render_page(page: &Page) -> Outcome<String> {
47 let mut ink = String::new();
48 let mut tspans = String::new();
49 let mut seen = false;
50 res!(render_slice(&page.frame.placed, &mut ink, &mut tspans, &mut seen));
51 Ok(assemble(page, &ink, &tspans))
52}
53
54/// Renders one page through the emit memo. The body frame (`placed[..body_len]`) is content-hashed;
55/// a hit reuses its previously rendered ink and selectable-text tspans, a miss renders and stores them.
56/// The furniture beyond the body -- the running head and folio, whose folio differs page to page -- is
57/// always drawn fresh and concatenated on, so the assembled bytes are identical to a whole-frame render
58/// of the same page. A page with no recorded body split (`body_len == usize::MAX`, every non-memo path)
59/// treats its whole frame as body, so a first, cold compile caches exactly what it drew.
60pub fn render_page_memo(page: &Page, memo: &mut Memo) -> Outcome<String> {
61 let split = page.body_len();
62 let body = &page.frame.placed[..split];
63 let furn = &page.frame.placed[split..];
64
65 let key = page_key(page, body);
66 let (body_ink, body_tspans, seen_after) = match memo.page_lookup(key) {
67 Some(e) => (e.body_ink, e.body_tspans, e.seen_text),
68 None => {
69 let mut ink = String::new();
70 let mut tspans = String::new();
71 let mut seen = false;
72 res!(render_slice(body, &mut ink, &mut tspans, &mut seen));
73 memo.page_store(key, ink.clone(), tspans.clone(), seen);
74 (ink, tspans, seen)
75 },
76 };
77
78 // The furniture, drawn fresh, its selectable tspans continuing the body's interword spacing.
79 let mut furn_ink = String::new();
80 let mut furn_tspans = String::new();
81 let mut seen = seen_after;
82 res!(render_slice(furn, &mut furn_ink, &mut furn_tspans, &mut seen));
83
84 let ink = fmt!("{}{}", body_ink, furn_ink);
85 let tspans = fmt!("{}{}", body_tspans, furn_tspans);
86 Ok(assemble(page, &ink, &tspans))
87}
88
89/// The stable identity of a whole page for the changed-only delta ([`crate::delta`]): the content hash of
90/// its ENTIRE frame -- body and furniture (running head, folio) alike -- so two compiles yield the same id
91/// for a page exactly when it renders to the same SVG bytes. Deliberately a superset of [`page_key`], which
92/// hashes the body alone for the emit memo (whose furniture is always redrawn fresh); the delta needs the
93/// folio in the key, since the consumer caches and reuses the whole page SVG by this id, and a page whose
94/// only change is its printed folio renders differently and must be resent.
95pub(crate) fn page_id(page: &Page) -> u64 {
96 page_key(page, &page.frame.placed)
97}
98
99/// The content key of a page's body frame: its geometry and every body-placed item's position, size and
100/// ink. Furniture is excluded (it hashes nothing here); the verso mirror shift is already baked into the
101/// placed positions, so a page that changes parity hashes differently and misses, which is correct -- its
102/// body sits at different coordinates. Theme is not in the key because it is baked into the shaped runs
103/// (a run's glyph ids and colour already reflect it) and into the caller's global fingerprint besides.
104fn page_key(page: &Page, body: &[Placed]) -> u64 {
105 let mut h = Fnv::new();
106 h.write(b"page");
107 let size = page.geom.media_box();
108 h.write_usize(size.x.as_usize());
109 h.write_usize(size.y.as_usize());
110 for p in body {
111 h.write_i32(p.x.raw());
112 h.write_i32(p.y.raw());
113 h.write_i32(p.dims.width.raw());
114 h.write_i32(p.dims.height.raw());
115 h.write_i32(p.dims.depth.raw());
116 match &p.kind {
117 PlacedKind::Rule => h.write_u8(0),
118 PlacedKind::Reserved => h.write_u8(1),
119 PlacedKind::Text(s) => { h.write_u8(2); s.hash_into(&mut h); },
120 PlacedKind::Graphic(g) => { h.write_u8(3); hash_graphic(g, &mut h); },
121 }
122 }
123 h.finish()
124}
125
126/// Folds a placed graphic into a page key: each op by its kind, its paint, and its geometry. A path is
127/// hashed by the very `d` string the writer emits, so two paths hash alike exactly when they draw alike.
128fn hash_graphic(g: &Graphic, h: &mut Fnv) {
129 for op in &g.ops {
130 match op {
131 DrawOp::Fill { path, colour } => {
132 h.write_u8(0);
133 h.write_str(&write_path_data(path));
134 h.write(&[colour.r, colour.g, colour.b, colour.a]);
135 },
136 DrawOp::Stroke { path, colour, width } => {
137 h.write_u8(1);
138 h.write_str(&write_path_data(path));
139 h.write(&[colour.r, colour.g, colour.b, colour.a]);
140 h.write_f32(*width);
141 },
142 DrawOp::Image { image, x, y, w, h: ht } => {
143 h.write_u8(2);
144 h.write_usize(image.width);
145 h.write_usize(image.height);
146 h.write(&image.rgba);
147 h.write_f32(*x);
148 h.write_f32(*y);
149 h.write_f32(*w);
150 h.write_f32(*ht);
151 },
152 }
153 }
154}
155
156/// Wraps the visible ink and the selectable tspans of a page in the SVG document shell -- the `<svg>`
157/// viewport, the white backing rectangle, the `.tsel` style, and the one page-wide `<text>` layer -- so
158/// every render path, memo or not, produces the same bytes for the same content.
159fn assemble(page: &Page, ink: &str, tspans: &str) -> String {
160 let size = page.geom.media_box();
161 let w = size.x.as_usize();
162 let h = size.y.as_usize();
163 let mut out = String::new();
164 out.push_str(&fmt!(
165 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">\n",
166 w, h, w, h));
167 out.push_str(&fmt!(
168 "<rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"#ffffff\"/>\n", w, h));
169 // `.tsel` (Typst.ts's own name for the same idea) is the selectable text layer's class: transparent,
170 // so it draws nothing over the glyph outlines below, and pointer-events left at the SVG default
171 // (not `none`) so a mouse drag still hits real text nodes rather than only outline paths.
172 out.push_str("<style>.tsel { fill: transparent; }</style>\n");
173 out.push_str(ink);
174 if !tspans.is_empty() {
175 out.push_str(&fmt!(" <text class=\"tsel\">{}</text>\n", tspans));
176 }
177 out.push_str("</svg>\n");
178 out
179}
180
181/// Renders one slice of a page's placed items into two buffers: `ink` gets the visible glyph outlines,
182/// rules and graphics; `tspans` gets the invisible selectable text layer. `seen_text` threads across
183/// slices, so the furniture's first run takes its leading interword space exactly as it would in a
184/// single-pass whole-frame render. This is the per-item logic [`render_page`] always ran, split out so
185/// the body and the furniture can be rendered -- and the body cached -- independently.
186///
187/// The visible pass runs first, in item order, then the selectable pass: Austenite's SVG carries only
188/// glyph outlines, which a browser can render but neither select nor search, so Typst.ts's answer -- a
189/// transparent text layer at the same baseline positions -- is mirrored on top. Every run's tspans join
190/// ONE page-wide `<text>` (assembled by [`assemble`]): Chromium's `window.find` was tested to fail across
191/// a boundary between two sibling `<text>` elements once their tspans carry per-glyph `x`/`y`, so a single
192/// element sidesteps it. The line breaker leaves the interword gap as pure position, not a space glyph, so
193/// a single invisible space is inserted ahead of every run but the first to restore it for search and copy.
194fn render_slice(
195 placed: &[Placed],
196 ink: &mut String,
197 tspans: &mut String,
198 seen_text: &mut bool,
199)
200 -> Outcome<()>
201{
202 // A half-point grey pen outlines a reservation, so a proof shows where a resolved value will sit
203 // without the box reading as content.
204 let pen = res!(Stroke::new(0.5));
205 let grey = Rgba::new(176, 176, 176, 255);
206
207 for p in placed {
208 // Real text is drawn glyph by glyph as filled outlines; a rule or a reservation as one rectangle.
209 if let PlacedKind::Text(shaped) = &p.kind {
210 res!(draw_text(ink, p.x, p.y, p.dims.height, shaped));
211 continue;
212 }
213 if let PlacedKind::Graphic(g) = &p.kind {
214 res!(draw_graphic(ink, p.x, p.y, g));
215 continue;
216 }
217
218 let x0 = p.x.to_pt() as f32;
219 let y0 = p.y.to_pt() as f32;
220 let x1 = (p.x + p.dims.width).to_pt() as f32;
221 let y1 = (p.y + p.dims.height + p.dims.depth).to_pt() as f32;
222
223 // A zero-area box has nothing to draw, and `Path::rect` would reject it.
224 if x1 <= x0 || y1 <= y0 {
225 continue;
226 }
227 let path = res!(Path::rect(Bounds::new(x0, y0, x1, y1)));
228 let d = write_path_data(&path);
229 let attrs = match &p.kind {
230 PlacedKind::Rule => presentation(Some(Rgba::BLACK), None),
231 PlacedKind::Reserved => presentation(None, Some((grey, &pen))),
232 PlacedKind::Text(_) => continue, // drawn above
233 PlacedKind::Graphic(_) => continue, // drawn above
234 };
235 ink.push_str(&fmt!(" <path d=\"{}\" {}/>\n", d, attrs));
236 }
237
238 for p in placed {
239 if let PlacedKind::Text(shaped) = &p.kind {
240 if res!(run_text_layer(tspans, p.x, p.y, p.dims.height, shaped, *seen_text)) {
241 *seen_text = true;
242 }
243 }
244 }
245 Ok(())
246}
247
248/// Draws a placed graphic: each op's path translated from the graphic's own frame to where the graphic
249/// landed, then filled or stroked. The paths are already y down in points, so a translation suffices --
250/// no flip, unlike a glyph outline.
251fn draw_graphic(
252 out: &mut String,
253 bx: Sp,
254 by: Sp,
255 graphic: &Graphic,
256)
257 -> Outcome<()>
258{
259 let t = Transform::translate(bx.to_pt() as f32, by.to_pt() as f32);
260 let ox = bx.to_pt() as f32;
261 let oy = by.to_pt() as f32;
262 for op in &graphic.ops {
263 match op {
264 DrawOp::Fill { path, colour } => {
265 let p = res!(path.transform(&t));
266 out.push_str(&fmt!(
267 " <path d=\"{}\" {}/>\n", write_path_data(&p), presentation(Some(*colour), None)));
268 },
269 DrawOp::Stroke { path, colour, width } => {
270 let pen = res!(Stroke::new(*width));
271 let p = res!(path.transform(&t));
272 out.push_str(&fmt!(
273 " <path d=\"{}\" {}/>\n", write_path_data(&p), presentation(None, Some((*colour, &pen)))));
274 },
275 DrawOp::Image { image, x, y, w, h } => {
276 // The raster is re-encoded to PNG and embedded as a base64 data URI in an `<image>`. Its
277 // frame is the page's own -- top-left, y down -- so the rectangle is placed directly, with
278 // no flip; `preserveAspectRatio="none"` lets the box already sized to the aspect fill.
279 let pm = res!(Pixmap::from_data(image.width, image.height, image.rgba.clone()));
280 let png = res!(pm.to_png());
281 let b64 = base64::encode(&png);
282 out.push_str(&fmt!(
283 " <image x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" preserveAspectRatio=\"none\" \
284 href=\"data:image/png;base64,{}\"/>\n",
285 ox + *x, oy + *y, *w, *h, b64));
286 },
287 }
288 }
289 Ok(())
290}
291
292/// Draws a placed run as filled glyph outlines. `height` is the line's own HBox height -- the face
293/// ascent for an ordinary line, or the cap height for a first line raised under the block-edge model
294/// (see `linebreak::set_lines`), whose glyphs then carry a compensating negative shift -- so
295/// `by + height` is the baseline either way. `bx`/`by` are the box's top-left.
296fn draw_text(
297 out: &mut String,
298 bx: Sp,
299 by: Sp,
300 height: Sp,
301 shaped: &ShapedText,
302)
303 -> Outcome<()>
304{
305 let base_x = bx.to_pt() as f32;
306 let base_y = (by + height).to_pt() as f32;
307 for glyph in &shaped.run().glyphs {
308 let path = res!(shaped.outline(glyph));
309 // A glyph with no ink -- a space -- carries an advance but nothing to fill.
310 if path.is_empty() {
311 continue;
312 }
313 // The outline is font-frame, y up; the page is y down. Flip in y, then move onto the baseline
314 // at the glyph's own offset. The run is shaped in points, so no scale beyond the flip.
315 let t = Transform::scale(1.0, -1.0)
316 .then(&Transform::translate(base_x + glyph.x, base_y - glyph.y));
317 let placed = res!(path.transform(&t));
318 out.push_str(&fmt!(
319 " <path d=\"{}\" {}/>\n",
320 write_path_data(&placed), presentation(Some(shaped.colour()), None)));
321 }
322 Ok(())
323}
324
325/// Appends a placed run's invisible, selectable tspans to the page's one `.tsel` text buffer: one
326/// `<tspan>` per inked glyph, each carrying the source text [`ShapedText::glyph_text`] maps that glyph
327/// to, its own `font-size` (runs on a page differ -- a heading against a caption), and sitting exactly
328/// on that glyph's own baseline position -- the same `(base_x + glyph.x, base_y - glyph.y)`
329/// [`draw_text`] paints the outline at, so the invisible character and the visible one it stands in for
330/// never drift apart. The mapping is the very one the PDF writer's `/ToUnicode` CMap uses, not a fresh
331/// derivation, so the two extraction paths can never disagree about what a glyph says.
332///
333/// `sep` asks for a leading space, ahead of this run's own tspans, standing in for the interword gap the
334/// line breaker never gives a glyph of its own (see the call site in `render_page`). Returns whether
335/// anything was appended, so the caller only counts a run that actually carried a character towards
336/// "there was a previous run to space this one from".
337fn run_text_layer(
338 buf: &mut String,
339 bx: Sp,
340 by: Sp,
341 height: Sp,
342 shaped: &ShapedText,
343 sep: bool,
344)
345 -> Outcome<bool>
346{
347 let base_x = bx.to_pt() as f32;
348 let base_y = (by + height).to_pt() as f32;
349 let size = shaped.size();
350 let texts = shaped.glyph_text();
351
352 let mut spans = String::new();
353 for (glyph, text) in shaped.run().glyphs.iter().zip(texts.iter()) {
354 // A glyph with no text of its own -- a later part of a ligature or decomposed mark, already
355 // claimed by an earlier glyph at the same cluster -- contributes no span; the earlier one already
356 // carries the character.
357 if text.is_empty() {
358 continue;
359 }
360 spans.push_str(&fmt!(
361 "<tspan x=\"{}\" y=\"{}\" font-size=\"{}\">{}</tspan>",
362 base_x + glyph.x, base_y - glyph.y, size, xml_escape(text)));
363 }
364 // A run with nothing inked -- entirely spaces -- has nothing to select.
365 if spans.is_empty() {
366 return Ok(false);
367 }
368 if sep {
369 buf.push_str(&fmt!("<tspan x=\"{}\" y=\"{}\" font-size=\"{}\"> </tspan>", base_x, base_y, size));
370 }
371 buf.push_str(&spans);
372 Ok(true)
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use crate::ir::{
379 LeafKind,
380 Node,
381 };
382 use crate::linebreak::break_paragraph;
383 use crate::page::{
384 Frame,
385 PageGeometry,
386 Placed,
387 };
388 use oxedyne_fe2o3_font::{
389 face::Role,
390 shape::Dir,
391 };
392 use std::sync::Arc;
393
394 /// A body paragraph set with a fill draws its glyphs in that colour, and a default paragraph stays
395 /// black. The paragraph is broken by [`break_paragraph`] with the fill threaded from the theme, then
396 /// its text leaves are placed and rendered, so the test exercises the whole thread from the line
397 /// breaker to the SVG paint. Black is asserted free of red -- the byte-identity the all-black corpus
398 /// rests on, since a black run's paint is exactly what it was before text carried a colour.
399 #[test]
400 fn a_paragraph_renders_in_its_set_fill() -> Outcome<()> {
401 let fonts = Arc::new(res!(crate::fonts::libertinus()));
402 let geom = PageGeometry::a4();
403 let measure = Sp::from_pt(400.0);
404 let size = Sp::from_pt(11.0);
405 let leading = Sp::from_pt(13.2);
406
407 // A run set red renders red glyphs.
408 let rnodes = res!(break_paragraph(
409 fonts.clone(), Role::Body, Dir::Ltr, size, "Red prose here", measure, leading, false, Rgba::opaque(255, 0, 0), None));
410 let rsvg = res!(render_text_leaves(geom, &rnodes));
411 assert!(rsvg.contains("fill=\"#ff0000\""), "a paragraph set red must draw red glyphs, found: {}", rsvg);
412
413 // The default fill (black) renders black glyphs and never red.
414 let bnodes = res!(break_paragraph(
415 fonts.clone(), Role::Body, Dir::Ltr, size, "Black prose here", measure, leading, false, Rgba::BLACK, None));
416 let bsvg = res!(render_text_leaves(geom, &bnodes));
417 assert!(bsvg.contains("fill=\"#000000\""), "a default paragraph must draw black glyphs");
418 assert!(!bsvg.contains("fill=\"#ff0000\""), "a default paragraph must never draw red");
419 Ok(())
420 }
421
422 /// The selectable text layer carries the same word the outlines draw, transparent, and the outlines
423 /// are unmoved by its presence -- the visible ink stays exactly what it was, this test's own name for
424 /// why the oracle's PDF hash (built from the same outlines, on the same path) is untouched by an
425 /// SVG-only addition.
426 #[test]
427 fn a_run_gets_an_invisible_selectable_twin() -> Outcome<()> {
428 let fonts = Arc::new(res!(crate::fonts::libertinus()));
429 let geom = PageGeometry::a4();
430 let shaped = res!(crate::font::ShapedText::new(fonts, Role::Body, Dir::Ltr, Sp::from_pt(11.0), "Oxegen"));
431 let dims = shaped.dims();
432 let mut frame = Frame::new();
433 frame.push(Placed::new(Sp::from_pt(60.0), Sp::from_pt(80.0), dims, PlacedKind::Text(shaped)));
434 let svg = res!(render_page(&Page::new(1, geom, frame)));
435
436 assert!(svg.contains("class=\"tsel\""), "a selectable text layer is drawn, found: {}", svg);
437 assert!(svg.contains(".tsel { fill: transparent; }"), "the layer is transparent");
438 // The word is recoverable letter by letter, as `window.find`/copy-paste would see it: each
439 // character sits in its own positioned `<tspan>`, in order.
440 for ch in "Oxegen".chars() {
441 assert!(svg.contains(&fmt!(">{}</tspan>", ch)), "'{}' is drawn as a selectable tspan, found: {}", ch, svg);
442 }
443 // The visible outlines are unaffected: still one filled black path per inked glyph, nothing new
444 // added to that part of the document.
445 assert!(svg.contains("fill=\"#000000\""), "the outline glyphs still draw in black");
446 Ok(())
447 }
448
449 /// Places every text leaf of a broken paragraph into a frame and renders it to SVG, so a test can see
450 /// the fill the line breaker set without standing up the whole driver.
451 fn render_text_leaves(geom: PageGeometry, nodes: &[Node]) -> Outcome<String> {
452 let mut frame = Frame::new();
453 let mut x = Sp::from_pt(60.0);
454 let y = Sp::from_pt(80.0);
455 for node in nodes {
456 place_text_leaves(node, &mut frame, &mut x, y);
457 }
458 render_page(&Page::new(1, geom, frame))
459 }
460
461 // Walks a node tree, placing each text leaf at the running pen so its glyphs reach the SVG.
462 fn place_text_leaves(node: &Node, frame: &mut Frame, x: &mut Sp, y: Sp) {
463 match node {
464 Node::Leaf(leaf) => {
465 if let LeafKind::Text(shaped) = &leaf.kind {
466 frame.push(Placed::new(*x, y, leaf.dims, PlacedKind::Text(shaped.clone())));
467 *x = Sp(x.raw() + leaf.dims.width.raw());
468 }
469 },
470 Node::HBox(b) | Node::VBox(b) => for child in &b.list { place_text_leaves(child, frame, x, y); },
471 _ => {},
472 }
473 }
474}