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 | |
| 10 | use crate::font::ShapedText; |
| 11 | use crate::ir::{ |
| 12 | DrawOp, |
| 13 | Graphic, |
| 14 | Sp, |
| 15 | }; |
| 16 | use crate::memo::{ |
| 17 | Fnv, |
| 18 | Memo, |
| 19 | }; |
| 20 | use crate::page::{ |
| 21 | Page, |
| 22 | Placed, |
| 23 | PlacedKind, |
| 24 | }; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::prelude::*; |
| 27 | use 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 | }; |
| 41 | use oxedyne_fe2o3_text::base64; |
| 42 | use 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. |
| 46 | pub 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. |
| 60 | pub 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. |
| 95 | pub(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. |
| 104 | fn 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. |
| 128 | fn 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. |
| 159 | fn 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. |
| 194 | fn 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. |
| 251 | fn 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. |
| 296 | fn 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". |
| 337 | fn 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)] |
| 376 | mod 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 | } |