Oregami
Repositories/oxedyne/fe2o3

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
14use crate::font::ShapedText;
15use crate::ir::{
16 DrawOp,
17 Graphic,
18 LinkTarget,
19 Sp,
20};
21use crate::page::{
22 Page,
23 PlacedKind,
24};
25
26use std::io::Write;
27
28use oxedyne_fe2o3_core::prelude::*;
29use 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.
47pub 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.
62pub 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.
69pub 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.
82pub 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.
89pub 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.
97pub 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.
145fn 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.
189fn 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)]
233mod 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}