oxedyne/fe2o3/fe2o3_austenite/src/emit/pdf.rs
13.6 KiB, 99 runs
created by r1870400018:36020, 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 PDF page writer. |
| 2 | //! |
| 3 | //! The parallel of [`super::svg`], over the very same placed frames. Boxes and graphics go to |
| 4 | //! `fe2o3_graphics`'s [`PdfWriter`] as fill and stroke operators. Text goes as text: each glyph is shown |
| 5 | //! by id from its face's own font file, embedded once and subset to the glyphs the document uses, with a |
| 6 | //! `/ToUnicode` from the same [`ShapedText::glyph_text`] the SVG text layer reads, so the PDF's text |
| 7 | //! selects, copies and searches. A face that cannot be embedded falls back to filled outlines in a Type-3 |
| 8 | //! font, which still extracts. |
| 9 | //! |
| 10 | //! A document is one file across all its pages, not a string per page, so this module's entry point is |
| 11 | //! [`render_document`] rather than the per-page `render_page` the [`super::Emitter`] enum uses for |
| 12 | //! SVG. |
| 13 | |
| 14 | use crate::font::ShapedText; |
| 15 | use crate::ir::{ |
| 16 | DrawOp, |
| 17 | Graphic, |
| 18 | LinkTarget, |
| 19 | Sp, |
| 20 | }; |
| 21 | use crate::page::{ |
| 22 | Page, |
| 23 | PlacedKind, |
| 24 | }; |
| 25 | |
| 26 | use std::io::Write; |
| 27 | |
| 28 | use oxedyne_fe2o3_core::prelude::*; |
| 29 | use oxedyne_fe2o3_graphics::{ |
| 30 | colour::Rgba, |
| 31 | path::{ |
| 32 | Bounds, |
| 33 | Path, |
| 34 | }, |
| 35 | pdf::{ |
| 36 | OutlineItem, |
| 37 | PdfPage, |
| 38 | PdfStream, |
| 39 | PdfWriter, |
| 40 | }, |
| 41 | transform::Transform, |
| 42 | }; |
| 43 | |
| 44 | /// Renders a whole document -- every page -- as one PDF file, held in a buffer. A convenience for a |
| 45 | /// short run; a whole book streams to a file with [`stream_document`] instead, which never holds more |
| 46 | /// than one page's outlines. The bytes are the same either way. |
| 47 | pub fn render_document(pages: &[Page]) -> Outcome<Vec<u8>> { |
| 48 | let mut writer = PdfWriter::new().with_compression(true); |
| 49 | for page in pages { |
| 50 | writer.add_page(res!(render_page(page))); |
| 51 | } |
| 52 | writer.to_bytes() |
| 53 | } |
| 54 | |
| 55 | /// Opens a page-at-a-time PDF stream over `out`, for a document of exactly `total` pages. |
| 56 | /// |
| 57 | /// This is the streaming half of the emitter, and the reason a whole-book compile is flat in memory: |
| 58 | /// the caller composes one page, calls [`write_page`] to serialise it to `out`, then drops the page's |
| 59 | /// frame, so neither the engine nor the writer ever holds every page's glyph outlines at once. Close |
| 60 | /// the stream with [`PdfStream::finish`] once all `total` pages are written. Compression is on, as |
| 61 | /// [`render_document`] leaves it, so the two produce identical bytes. |
| 62 | pub fn open_document<W: Write>(out: W, total: usize) -> Outcome<PdfStream<W>> { |
| 63 | PdfStream::new(out, total, true) |
| 64 | } |
| 65 | |
| 66 | /// As [`open_document`], but the file also carries a document outline (the viewer's bookmark side |
| 67 | /// panel), built by the caller from the heading table and the front-matter anchors. An empty outline |
| 68 | /// yields a file byte-identical to [`open_document`]'s. |
| 69 | pub fn open_document_with_outline<W: Write>( |
| 70 | out: W, |
| 71 | total: usize, |
| 72 | outline: Vec<OutlineItem>, |
| 73 | ) |
| 74 | -> Outcome<PdfStream<W>> |
| 75 | { |
| 76 | PdfStream::new_with_outline(out, total, true, outline) |
| 77 | } |
| 78 | |
| 79 | /// Renders one page's frame to the open PDF stream. The page's outlines live only for this call: the |
| 80 | /// [`PdfPage`] built here is written and dropped before returning, so the caller may drop the page's |
| 81 | /// frame the moment this returns. |
| 82 | pub fn write_page<W: Write>(stream: &mut PdfStream<W>, page: &Page) -> Outcome<()> { |
| 83 | stream.page(&res!(render_page(page))) |
| 84 | } |
| 85 | |
| 86 | /// Writes a page whose draw list was built elsewhere -- on a worker thread, so the SVG the same walk |
| 87 | /// produces runs off the writer's thread. The content stream is serialised here, in page order, because |
| 88 | /// that is where a font's object number and a Type-3 glyph's code are assigned deterministically. |
| 89 | pub fn write_built_page<W: Write>(stream: &mut PdfStream<W>, pdf_page: &PdfPage) -> Outcome<()> { |
| 90 | stream.page(pdf_page) |
| 91 | } |
| 92 | |
| 93 | /// Builds one page's draw list: a white ground, then each placed box as a fill or a stroke. |
| 94 | /// |
| 95 | /// The coordinates are the engine's page frame -- top-left origin, y down -- and are handed on |
| 96 | /// unflipped, since `fe2o3_graphics::pdf` flips the whole page itself. |
| 97 | pub fn render_page(page: &Page) -> Outcome<PdfPage> { |
| 98 | let w = page.geom.width.to_pt(); |
| 99 | let h = page.geom.height.to_pt(); |
| 100 | let mut out = PdfPage::new(w, h); |
| 101 | |
| 102 | // A white ground, matching the SVG writer's opaque background rectangle. |
| 103 | out.fill(res!(Path::rect(Bounds::new(0.0, 0.0, w as f32, h as f32))), Rgba::WHITE); |
| 104 | |
| 105 | // A half-point grey pen outlines a reservation, so a proof shows where a resolved value will sit |
| 106 | // without the box reading as content. |
| 107 | let grey = Rgba::new(176, 176, 176, 255); |
| 108 | |
| 109 | for placed in &page.frame.placed { |
| 110 | // Real text is shown glyph by glyph; a rule or a reservation is one rectangle. |
| 111 | if let PlacedKind::Text(shaped) = &placed.kind { |
| 112 | res!(draw_text(&mut out, placed.x, placed.y, placed.dims.height, shaped)); |
| 113 | continue; |
| 114 | } |
| 115 | if let PlacedKind::Graphic(g) = &placed.kind { |
| 116 | res!(draw_graphic(&mut out, placed.x, placed.y, g)); |
| 117 | continue; |
| 118 | } |
| 119 | |
| 120 | let x0 = placed.x.to_pt() as f32; |
| 121 | let y0 = placed.y.to_pt() as f32; |
| 122 | let x1 = (placed.x + placed.dims.width).to_pt() as f32; |
| 123 | let y1 = (placed.y + placed.dims.height + placed.dims.depth).to_pt() as f32; |
| 124 | |
| 125 | // A zero-area box has nothing to draw, and `Path::rect` would reject it. |
| 126 | if x1 <= x0 || y1 <= y0 { |
| 127 | continue; |
| 128 | } |
| 129 | let path = res!(Path::rect(Bounds::new(x0, y0, x1, y1))); |
| 130 | match &placed.kind { |
| 131 | PlacedKind::Rule => out.fill(path, Rgba::BLACK), |
| 132 | PlacedKind::Reserved => out.stroke(path, grey, 0.5), |
| 133 | PlacedKind::Text(_) => continue, // drawn above |
| 134 | PlacedKind::Graphic(_) => continue, // drawn above |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // The running head and folio arrive as `PlacedKind::Text` and are shown with the body, above. This writer adds no page furniture of its own. |
| 139 | Ok(out) |
| 140 | } |
| 141 | |
| 142 | /// Draws a placed graphic: each op's path translated to where the graphic landed, then filled or |
| 143 | /// stroked. The paths are y down in points already, so only a translation is needed; the PDF writer |
| 144 | /// flips the whole page once, which leaves the graphic the right way up like the rest of the page. |
| 145 | fn draw_graphic( |
| 146 | out: &mut PdfPage, |
| 147 | bx: Sp, |
| 148 | by: Sp, |
| 149 | graphic: &Graphic, |
| 150 | ) |
| 151 | -> Outcome<()> |
| 152 | { |
| 153 | let t = Transform::translate(bx.to_pt() as f32, by.to_pt() as f32); |
| 154 | let ox = bx.to_pt(); |
| 155 | let oy = by.to_pt(); |
| 156 | // A linked graphic (the meta page's "Made with AI" chip) draws a clickable link annotation over its |
| 157 | // placement box, in the same y-down engine frame the ink is placed in; the writer flips it into PDF |
| 158 | // space. Only the mark carries the link, matching the template, where the words beside it are plain. |
| 159 | // v0 draws only an external URI annotation here; an internal `LinkTarget::Anchor` needs the ledger to |
| 160 | // resolve its destination page, which this per-graphic call does not hold, so it is left for a |
| 161 | // follow-up (Pearl already carries the internal target for a reader that has the ledger). |
| 162 | if let Some(LinkTarget::Uri(url)) = &graphic.link { |
| 163 | let w = graphic.dims.width.to_pt(); |
| 164 | let h = (graphic.dims.height + graphic.dims.depth).to_pt(); |
| 165 | out.link(ox, oy, w, h, url.clone()); |
| 166 | } |
| 167 | for op in &graphic.ops { |
| 168 | match op { |
| 169 | DrawOp::Fill { path, colour } => out.fill(res!(path.transform(&t)), *colour), |
| 170 | DrawOp::Stroke { path, colour, width } => out.stroke(res!(path.transform(&t)), *colour, (*width).into()), |
| 171 | DrawOp::Image { image, x, y, w, h } => { |
| 172 | // The raster fills its rectangle at the graphic's placement; the PDF writer embeds it as an |
| 173 | // image XObject, straight RGB with a soft mask only when a sample is translucent. |
| 174 | let (rgb, alpha) = crate::image::split_rgba(image); |
| 175 | out.image( |
| 176 | rgb, alpha, image.width, image.height, |
| 177 | ox + *x as f64, oy + *y as f64, *w as f64, *h as f64); |
| 178 | }, |
| 179 | } |
| 180 | } |
| 181 | Ok(()) |
| 182 | } |
| 183 | |
| 184 | /// Shows a placed run: each glyph by id from its embedded face, or as a filled outline when the face |
| 185 | /// cannot be embedded. `height` is the line's own HBox height -- the face |
| 186 | /// ascent for an ordinary line, or the cap height for a first line raised under the block-edge model |
| 187 | /// (see `linebreak::set_lines`), whose glyphs then carry a compensating negative shift -- so |
| 188 | /// `by + height` is the baseline either way. `bx`/`by` are the box's top-left. |
| 189 | fn draw_text( |
| 190 | out: &mut PdfPage, |
| 191 | bx: Sp, |
| 192 | by: Sp, |
| 193 | height: Sp, |
| 194 | shaped: &ShapedText, |
| 195 | ) |
| 196 | -> Outcome<()> |
| 197 | { |
| 198 | let base_x = bx.to_pt() as f32; |
| 199 | let base_y = (by + height).to_pt() as f32; |
| 200 | |
| 201 | // The source scalar(s) each glyph stands for, for the font's /ToUnicode -- the very mapping the SVG |
| 202 | // writer's selectable text layer draws on, so the two never disagree about what a glyph stands for. |
| 203 | let texts = shaped.glyph_text(); |
| 204 | |
| 205 | for (glyph, text) in shaped.run().glyphs.iter().zip(texts.into_iter()) { |
| 206 | // The pen: x the glyph's left, y its baseline, in the engine's top-left y-down frame. |
| 207 | let x = base_x + glyph.x; |
| 208 | let y = base_y - glyph.y; |
| 209 | if let Some(prog) = res!(shaped.program(glyph)) { |
| 210 | // Every glyph is shown, a space included: its text is what puts the word gap into a copy. |
| 211 | let gid = match u16::try_from(glyph.id) { |
| 212 | Ok(g) => g, |
| 213 | Err(_) => return Err(err!( |
| 214 | "Glyph id {} exceeds the 16 bits a font program can index.", glyph.id; Invalid, Range)), |
| 215 | }; |
| 216 | out.text(prog, gid, x, y, shaped.size(), shaped.colour(), text); |
| 217 | continue; |
| 218 | } |
| 219 | // The writer stores this canonical outline once and shows it at the run's point size. A glyph with |
| 220 | // no ink -- a space -- has an empty outline and is skipped, exactly as the SVG writer skips it, so the |
| 221 | // two arms place the same marks; the viewer infers word gaps from the glyph positions. |
| 222 | let outline = res!(shaped.outline_canonical(glyph)); |
| 223 | if outline.is_empty() { |
| 224 | continue; |
| 225 | } |
| 226 | // The writer flips the outline back to y up within the page's y-flip, so the glyph reads upright. |
| 227 | out.glyph(outline, x, y, shaped.size(), glyph.adv, shaped.colour(), text); |
| 228 | } |
| 229 | Ok(()) |
| 230 | } |
| 231 | |
| 232 | #[cfg(test)] |
| 233 | mod tests { |
| 234 | use super::*; |
| 235 | use crate::ir::{ |
| 236 | DrawOp, |
| 237 | Dims, |
| 238 | Graphic, |
| 239 | Sp, |
| 240 | }; |
| 241 | use crate::page::{ |
| 242 | Frame, |
| 243 | Page, |
| 244 | PageGeometry, |
| 245 | Placed, |
| 246 | PlacedKind, |
| 247 | }; |
| 248 | use std::sync::Arc; |
| 249 | |
| 250 | #[test] |
| 251 | fn text_embeds_its_font_and_extracts_via_tounicode() -> Outcome<()> { |
| 252 | // A shaped word is shown from its embedded CFF face, and the font's /ToUnicode CMap maps its glyph |
| 253 | // ids back to the source characters, so a viewer extracts the real word. Built uncompressed, so the |
| 254 | // CMap is readable straight from the bytes. |
| 255 | use crate::font::ShapedText; |
| 256 | use oxedyne_fe2o3_font::{ |
| 257 | face::Role, |
| 258 | shape::Dir, |
| 259 | }; |
| 260 | |
| 261 | let fonts = Arc::new(res!(crate::fonts::libertinus())); |
| 262 | let geom = PageGeometry::a4(); |
| 263 | let shaped = res!(ShapedText::new(fonts, Role::Body, Dir::Ltr, Sp::from_pt(11.0), "Oxegen")); |
| 264 | let tdims = shaped.dims(); |
| 265 | let mut frame = Frame::new(); |
| 266 | frame.push(Placed::new(Sp::from_pt(60.0), Sp::from_pt(80.0), tdims, PlacedKind::Text(shaped))); |
| 267 | let page = Page::new(1, geom, frame); |
| 268 | |
| 269 | let pdf_page = res!(render_page(&page)); |
| 270 | let mut w = PdfWriter::new(); // uncompressed, so the CMap is legible in the bytes |
| 271 | w.add_page(pdf_page); |
| 272 | let bytes = res!(w.to_bytes()); |
| 273 | let text = String::from_utf8_lossy(&bytes); |
| 274 | |
| 275 | assert!(text.contains("/Subtype /Type0"), "the word is shown from a composite font"); |
| 276 | assert!(text.contains("/Subtype /CIDFontType0 "), "over a CFF CIDFont"); |
| 277 | assert!(text.contains("/Subtype /CIDFontType0C"), "whose program is embedded"); |
| 278 | assert!(text.contains("+LibertinusSerif-Regular"), "under a subset tag and its PostScript name"); |
| 279 | assert!(!text.contains("/Subtype /Type3"), "no glyph falls back to outlines"); |
| 280 | assert!(text.contains(" Tj\n") || text.contains(" TJ\n"), "the glyphs are shown with text operators"); |
| 281 | assert!(text.contains("beginbfchar"), "a ToUnicode CMap carries character mappings"); |
| 282 | // The distinct letters of "Oxegen" appear as UTF-16BE destinations in the CMap. |
| 283 | for (ch, hex) in [('O', "004F"), ('x', "0078"), ('e', "0065"), ('g', "0067"), ('n', "006E")] { |
| 284 | assert!(text.contains(&fmt!("> <{}>", hex)), |
| 285 | "the CMap maps '{}' (U+{}) so the word is extractable", ch, hex); |
| 286 | } |
| 287 | Ok(()) |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn a_linked_graphic_emits_a_link_annotation() -> Outcome<()> { |
| 292 | // A placed graphic carrying a link (the meta page's "Made with AI" chip) draws a PDF link annotation |
| 293 | // over its box; a graphic with no link draws none, so the SVG-style plain image is unchanged. |
| 294 | let geom = PageGeometry::new(Sp::from_pt(200.0), Sp::from_pt(300.0), Sp::from_pt(20.0)); |
| 295 | let rect = res!(Path::rect(Bounds::new(0.0, 0.0, 36.0, 36.0))); |
| 296 | let graphic = Graphic::new( |
| 297 | vec![DrawOp::Fill { path: rect, colour: Rgba::BLACK }], |
| 298 | Dims::new(Sp::from_pt(36.0), Sp::from_pt(36.0), Sp::ZERO)) |
| 299 | .with_link("https://need2know.ai/with-ai/doc".to_string()); |
| 300 | let mut frame = Frame::new(); |
| 301 | frame.push(Placed::new( |
| 302 | Sp::from_pt(50.0), Sp::from_pt(80.0), graphic.dims, |
| 303 | PlacedKind::Graphic(Arc::new(graphic)))); |
| 304 | let page = Page::new(1, geom, frame); |
| 305 | let bytes = res!(render_document(&[page])); |
| 306 | let text = String::from_utf8_lossy(&bytes); |
| 307 | assert!(text.contains("/Subtype /Link"), "a link annotation is emitted, found: {}", text); |
| 308 | assert!(text.contains("/S /URI /URI (https://need2know.ai/with-ai/doc)"), "the URI action is written"); |
| 309 | // The box top-left (50, 80), 36 by 36, flips on a 300pt page to [50, 300-116, 86, 300-80] = [50 184 86 220]. |
| 310 | assert!(text.contains("/Rect [50 184 86 220]"), "the rectangle flips into PDF space, found: {}", text); |
| 311 | Ok(()) |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn an_unlinked_graphic_emits_no_annotation() -> Outcome<()> { |
| 316 | let geom = PageGeometry::new(Sp::from_pt(200.0), Sp::from_pt(300.0), Sp::from_pt(20.0)); |
| 317 | let rect = res!(Path::rect(Bounds::new(0.0, 0.0, 36.0, 36.0))); |
| 318 | let graphic = Graphic::new( |
| 319 | vec![DrawOp::Fill { path: rect, colour: Rgba::BLACK }], |
| 320 | Dims::new(Sp::from_pt(36.0), Sp::from_pt(36.0), Sp::ZERO)); |
| 321 | let mut frame = Frame::new(); |
| 322 | frame.push(Placed::new( |
| 323 | Sp::from_pt(50.0), Sp::from_pt(80.0), graphic.dims, |
| 324 | PlacedKind::Graphic(Arc::new(graphic)))); |
| 325 | let page = Page::new(1, geom, frame); |
| 326 | let bytes = res!(render_document(&[page])); |
| 327 | let text = String::from_utf8_lossy(&bytes); |
| 328 | assert!(!text.contains("/Annots"), "no annotation array without a link"); |
| 329 | assert!(!text.contains("/Subtype /Link"), "no link annotation without a link"); |
| 330 | Ok(()) |
| 331 | } |
| 332 | } |