Skip to main content

brep_render/sheets/
pdf.rs

1//! The sheet's PDF output contract — a minimal PDF 1.4 writer, no crate (1.7
2//! when it carries a 3D model; see the last section).
3//!
4//! The same [`SheetDrawing`] the SVG writer serialises, written as pages of
5//! **stroked paths and text**: ONE FILE for a list of sheets, one page per
6//! sheet in the order given, each page's `MediaBox` its own paper size in
7//! points (the paper is millimetres, a point is 1/72 inch), each drawing in a
8//! single uncompressed content stream, and text set in the standard Helvetica
9//! base font with WinAnsi encoding. No compression, no embedded font, no
10//! transparency, no xobject — a file a text editor can read, whose xref table
11//! points at real byte offsets. Every byte is ASCII: a character outside
12//! printable ASCII is written as an octal escape inside its string literal, so
13//! the file has no binary section and needs no binary marker.
14//!
15//! # The object graph
16//!
17//! ```text
18//! 1  Catalog   /Pages 2 0 R
19//! 2  Pages     /Count n  /Kids [5 0 R 7 0 R … (5 + 2(n−1)) 0 R]
20//! 3  Font      Helvetica, WinAnsiEncoding — shared by every page
21//! 4  Info      /Title (the document) /Producer (BREP) /HiddenLine (…)
22//! 5  Page 1    /Parent 2 0 R  /MediaBox [0 0 w₁ h₁]  /Contents 6 0 R
23//! 6  stream    page 1's marks
24//! 7  Page 2    /Parent 2 0 R  /MediaBox [0 0 w₂ h₂]  /Contents 8 0 R
25//! 8  stream    page 2's marks
26//! …
27//! ```
28//!
29//! The page tree is FLAT: one `Pages` node whose kids are every page. A
30//! balanced tree only matters to a reader seeking into thousands of pages,
31//! and a drawing set is not that; flat is what the specification's own
32//! minimal example is. A single sheet is the one-page case of the same writer
33//! ([`to_pdf`]), not a second one.
34//!
35//! # The frame
36//!
37//! PDF's origin is the page's BOTTOM-left with y running UP; a sheet is measured
38//! from the TOP-left with y running down. Every coordinate therefore goes
39//! through [`point`], which flips y about the page height — and a text run's
40//! rotation flips with it, which is why the text matrix turns by the NEGATIVE of
41//! the drawing's angle ([`text_matrix`]).
42//!
43//! # What the PDF cannot carry that the SVG does
44//!
45//! Two things, both about what a file says ABOUT itself rather than what it
46//! draws:
47//!
48//! - **The grouping and the ids.** An SVG placement is a `<g id="SV2"
49//!   data-view="VIEW1" data-scale="4">` a reader can select, script or style,
50//!   and a placement that could not project carries its reason in a `<desc>`.
51//!   This writer draws the marks only: the geometry is identical, the labelling
52//!   is not there. A refused placement is therefore INVISIBLE in the PDF where
53//!   the SVG says why it is blank — the panel and `sheet_state` are where that
54//!   answer lives for both.
55//! - **Characters outside WinAnsi.** The SVG hands any Unicode string to
56//!   whatever renders it; a Helvetica base font is a 256-glyph encoding. What is
57//!   drawn is what [`encode_winansi`] can map: Latin-1 plus the CP1252
58//!   punctuation, with `⌀` written as `Ø` (the substitution every drawing
59//!   standard already treats as the diameter sign) and `−` U+2212 as a hyphen.
60//!   Anything else — the fourteen GD&T characteristic symbols an FCF cell draws,
61//!   CJK in a note — becomes `?`. Everything the dimension formatter itself
62//!   emits (digits, `.`, `±`, `°`, `⌀`, parentheses, `/`) IS carried.
63//!
64//! # Which pass drew the lines
65//!
66//! The Info dictionary's `/HiddenLine` says it for the whole file: `(analytic)`
67//! when every placement that projected was drawn by the exact pass, and
68//! `(mesh: Sheet 1 SV2, …)` naming each one the mesh approximation drew — a
69//! placement whose B-rep had not reached the scene when the file was written
70//! ([`ViewDrawing::hidden_line`]). An export from a background runner can
71//! write a sheet no one has shown yet, and this is how that file says so.
72//!
73//! Everything else the SVG has is here: the same millimetres, the same visible
74//! edge runs, the same annotation lines and text, the same border and title
75//! block, the same stroke widths, and the same real text (selectable and
76//! searchable, not stroked outlines).
77//!
78//! # The one exception: a live 3D model
79//!
80//! [`write_pdf_3d`] can carry the model in 3D ([`super::pdf3d`]) — over a
81//! placement marked `threeD`, and on a last page given over to it. That file
82//! is PDF **1.7**, its pages carry `/Annots` (the 3D annotations and the view
83//! buttons), and each 3D annotation has a form xobject as its (empty)
84//! appearance. It is still all ASCII: the U3D stream is ASCII85-encoded. A
85//! file that asks for no 3D is not touched by any of this — [`write_pdf`] is
86//! the no-3D case of the same writer, and its bytes are what they were.
87
88use super::frame::FrameDrawing;
89use super::project::{SheetDrawing, SheetText, ViewDrawing};
90use super::svg::{ANNOTATION_WIDTH, CAP_PER_EM, EDGE_WIDTH, FRAME_WIDTH};
91
92/// PDF user-space units per millimetre — a point is 1/72 of an inch.
93pub const PT_PER_MM: f64 = 72.0 / 25.4;
94
95/// The one font every PDF reader has without embedding.
96const BASE_FONT: &str = "Helvetica";
97
98/// Serialise one sheet as a one-page PDF titled with the sheet's name — the
99/// one-page case of [`write_pdf`].
100pub fn to_pdf(sheet: &SheetDrawing) -> Vec<u8> {
101    write_pdf(&sheet.name, &[sheet])
102}
103
104/// Serialise `pages` as one PDF titled `title`, **one page per sheet**, in
105/// order, each at its own paper size.
106pub fn write_pdf(title: &str, pages: &[&SheetDrawing]) -> Vec<u8> {
107    write_pdf_3d(title, pages, None)
108}
109
110/// [`write_pdf`], carrying a live 3D model ([`super::pdf3d`]) when `three_d`
111/// is given: a 3D annotation over each placement it names, and — when it asks
112/// for one — a last page given over to the model with a button per saved
113/// view.
114///
115/// With 3D the file is PDF **1.7** (per-view node visibility is a 1.7 feature)
116/// and pages carry `/Annots`; the rest of this module's contract holds — the
117/// U3D stream is ASCII85-encoded, so every byte is still ASCII. Without it the
118/// output is byte for byte [`write_pdf`]'s.
119///
120/// ```text
121/// 5 … 4+2n      the sheet pages, (page, contents) pairs as above
122/// 5+2n, 6+2n    the 3D page and its contents, when asked for
123/// next          the 3D stream (the U3D, and every saved view in /VA)
124/// then          per placement: its 3D annotation, its (empty) appearance
125/// then          the 3D page's annotation, its appearance, a link per view
126/// ```
127pub fn write_pdf_3d(title: &str, pages: &[&SheetDrawing], three_d: Option<&super::pdf3d::Pdf3d>) -> Vec<u8> {
128    let mut pdf = Writer::default();
129    pdf.header(if three_d.is_some() { "1.7" } else { "1.4" });
130    // Object numbers are assigned up front so the catalogue, the page tree and
131    // the pages can reference each other: 1 catalogue, 2 page tree, 3 font,
132    // 4 info, then a (page, contents) pair per sheet.
133    let first_page = 5;
134    let extra_page = three_d.and_then(|three_d| three_d.page.as_ref());
135    let page_count = pages.len() + usize::from(extra_page.is_some());
136    let plan = three_d.map(|three_d| super::pdf3d::Plan::new(three_d, first_page + page_count * 2, pages.len()));
137    let kids: Vec<String> =
138        (0..page_count).map(|index| format!("{} 0 R", first_page + index * 2)).collect();
139    pdf.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
140    pdf.object(
141        2,
142        &format!("<< /Type /Pages /Count {} /Kids [{}] >>", page_count, kids.join(" ")),
143    );
144    pdf.object(
145        3,
146        &format!(
147            "<< /Type /Font /Subtype /Type1 /BaseFont /{BASE_FONT} /Encoding /WinAnsiEncoding >>"
148        ),
149    );
150    pdf.object(
151        4,
152        &format!(
153            "<< /Title ({}) /Producer (BREP) /HiddenLine ({}) >>",
154            string_literal(title),
155            string_literal(&hidden_line(pages))
156        ),
157    );
158    for (index, sheet) in pages.iter().enumerate() {
159        let page = first_page + index * 2;
160        let contents = page + 1;
161        let (w, h) = (sheet.width_mm.max(1.0), sheet.height_mm.max(1.0));
162        let annots = plan.as_ref().map(|plan| plan.annots_on(index)).unwrap_or_default();
163        pdf.object(
164            page,
165            &format!(
166                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.4} {:.4}] \
167                 /Resources << /Font << /F1 3 0 R >> >> /Contents {contents} 0 R{annots} >>",
168                w * PT_PER_MM,
169                h * PT_PER_MM
170            ),
171        );
172        pdf.stream(contents, content_stream(sheet).as_bytes());
173    }
174    if let (Some(three_d), Some(plan)) = (three_d, plan.as_ref()) {
175        if let Some(extra) = extra_page {
176            let page = first_page + pages.len() * 2;
177            let (w, h) = (extra.width_mm.max(1.0), extra.height_mm.max(1.0));
178            pdf.object(
179                page,
180                &format!(
181                    "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.4} {:.4}] \
182                     /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R{} >>",
183                    w * PT_PER_MM,
184                    h * PT_PER_MM,
185                    page + 1,
186                    plan.annots_on(pages.len())
187                ),
188            );
189            pdf.stream(page + 1, model_page_stream(extra).as_bytes());
190        }
191        for (number, body) in plan.objects(three_d, pages, first_page) {
192            pdf.object(number, &body);
193        }
194    }
195    pdf.xref_and_trailer(1);
196    pdf.out
197}
198
199/// The Info dictionary's `/HiddenLine`: `analytic`, or `mesh: ` and the sheet
200/// and placement of every view the mesh approximation drew.
201fn hidden_line(pages: &[&SheetDrawing]) -> String {
202    let approximate: Vec<String> = pages
203        .iter()
204        .flat_map(|sheet| {
205            sheet
206                .views
207                .iter()
208                .filter(|view| view.hidden_line == super::project::HiddenLine::Mesh.label())
209                .map(move |view| format!("{} {}", sheet.name, view.id))
210        })
211        .collect();
212    if approximate.is_empty() {
213        super::project::HiddenLine::Analytic.label().to_string()
214    } else {
215        format!("{}: {}", super::project::HiddenLine::Mesh.label(), approximate.join(", "))
216    }
217}
218
219// ---------------------------------------------------------------------------
220// The content stream
221// ---------------------------------------------------------------------------
222
223/// One page's marks: the paper's furniture, then every placement's model lines
224/// and annotations — the SVG writer's order, so the two files layer alike.
225fn content_stream(sheet: &SheetDrawing) -> String {
226    let h = sheet.height_mm.max(1.0);
227    let mut out = String::new();
228    // Black, butt-free joins: the round caps the SVG asks for, so a thin
229    // polyline reads the same in both files.
230    out.push_str("0 G\n0 g\n1 J\n1 j\n");
231    if let Some(frame) = &sheet.frame {
232        frame_marks(frame, h, &mut out);
233    }
234    for view in &sheet.views {
235        view_marks(view, h, &mut out);
236    }
237    // The SHEET's own dimensions, last: they are drawn over the placements
238    // exactly as the SVG layers them.
239    for dimension in &sheet.dimensions {
240        if !dimension.lines.is_empty() {
241            out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
242            for line in &dimension.lines {
243                out.push_str(&path(line, h));
244            }
245        }
246        for text in &dimension.texts {
247            out.push_str(&text_marks(text, h));
248        }
249    }
250    // …and the ordinate sets beside them, in the SVG's own order.
251    for set in &sheet.ordinates {
252        if !set.lines.is_empty() {
253            out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
254            for line in &set.lines {
255                out.push_str(&path(line, h));
256            }
257        }
258        for text in &set.texts {
259            out.push_str(&text_marks(text, h));
260        }
261    }
262    out
263}
264
265/// The 3D page's marks: its title, the default view drawn as the sheets draw
266/// a placement (what every reader without 3D shows, and what the 3D box
267/// covers once active), the view buttons' boxes and labels, and the caption.
268fn model_page_stream(page: &super::pdf3d::Page3d) -> String {
269    let h = page.height_mm.max(1.0);
270    let mut out = String::from("0 G\n0 g\n1 J\n1 j\n");
271    out.push_str(&text_marks(&SheetText::flat(page.title.clone(), page.title_at, page.title_mm), h));
272    match &page.poster {
273        Some(poster) => view_marks(poster, h, &mut out),
274        None => {
275            // No drawing to show: the box outlined, so the page is not blank
276            // where the model will be.
277            let [x0, y0, x1, y1] = page.box_mm;
278            out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
279            out.push_str(&path(&[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]], h));
280        }
281    }
282    if !page.buttons.is_empty() {
283        out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
284        for button in &page.buttons {
285            let [x0, y0, x1, y1] = button.rect_mm;
286            out.push_str(&path(&[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]], h));
287            let centre = [(x0 + x1) * 0.5, (y0 + y1) * 0.5];
288            out.push_str(&text_marks(&SheetText::flat(button.label.clone(), centre, page.button_text_mm), h));
289        }
290    }
291    for (line, at) in page.caption.iter().zip(&page.caption_at) {
292        out.push_str(&text_marks(&SheetText::flat(line.clone(), *at, page.caption_mm), h));
293    }
294    out
295}
296
297fn frame_marks(frame: &FrameDrawing, h: f64, out: &mut String) {
298    if !frame.lines.is_empty() {
299        out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
300        for line in &frame.lines {
301            out.push_str(&path(line, h));
302        }
303    }
304    for text in &frame.texts {
305        out.push_str(&text_marks(text, h));
306    }
307    // The revision table in the frame's own stroke, after the stamp — the
308    // SVG's order.
309    if let Some(table) = &frame.revision_table {
310        out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
311        for line in &table.lines {
312            out.push_str(&path(line, h));
313        }
314        for text in &table.texts {
315            out.push_str(&text_marks(text, h));
316        }
317    }
318}
319
320fn view_marks(view: &ViewDrawing, h: f64, out: &mut String) {
321    if !view.edges.is_empty() {
322        out.push_str(&format!("{:.4} w\n", EDGE_WIDTH * PT_PER_MM));
323        for run in &view.edges {
324            out.push_str(&path(run, h));
325        }
326    }
327    // The SECTION's hatch and caption, and the section LINES this placement
328    // carries — the SVG's own layering, so the two files read alike.
329    if let Some(section) = &view.section {
330        if !section.hatch.is_empty() {
331            out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
332            for line in &section.hatch {
333                out.push_str(&path(line, h));
334            }
335        }
336        for text in &section.texts {
337            out.push_str(&text_marks(text, h));
338        }
339    }
340    for mark in &view.section_marks {
341        if !mark.lines.is_empty() {
342            out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
343            for line in &mark.lines {
344                out.push_str(&path(line, h));
345            }
346        }
347        for text in &mark.texts {
348            out.push_str(&text_marks(text, h));
349        }
350    }
351    // A DETAIL's boundary and caption, then the detail circles drawn on this
352    // placement — again the SVG's order.
353    if let Some(detail) = &view.detail {
354        out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
355        out.push_str(&path(&detail.circle, h));
356        for text in &detail.texts {
357            out.push_str(&text_marks(text, h));
358        }
359    }
360    for mark in &view.detail_marks {
361        if !mark.lines.is_empty() {
362            out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
363            for line in &mark.lines {
364                out.push_str(&path(line, h));
365            }
366        }
367        for text in &mark.texts {
368            out.push_str(&text_marks(text, h));
369        }
370    }
371    for annotation in &view.annotations {
372        if !annotation.lines.is_empty() {
373            out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
374            for line in &annotation.lines {
375                out.push_str(&path(line, h));
376            }
377        }
378        for text in &annotation.texts {
379            out.push_str(&text_marks(text, h));
380        }
381    }
382}
383
384/// A polyline as one stroked path: `m`, `l`…, `S`.
385fn path(points: &[[f64; 2]], h: f64) -> String {
386    let mut out = String::new();
387    for (index, p) in points.iter().enumerate() {
388        let (x, y) = point(*p, h);
389        out.push_str(&format!("{x:.4} {y:.4} {}\n", if index == 0 { "m" } else { "l" }));
390    }
391    if !out.is_empty() {
392        out.push_str("S\n");
393    }
394    out
395}
396
397/// A text run as `BT … Tm (…) Tj ET`, centred on its anchor.
398///
399/// The run's own basis becomes the text matrix. `Tf` still carries the size
400/// the cap height asks for, so `a b c d` is that basis divided by the height —
401/// a rotation for a flat or merely rotated run, and the sheared pair an
402/// oblique annotation plane projects to, which a rotation could not express.
403fn text_marks(text: &SheetText, h: f64) -> String {
404    let literal = string_literal(&text.text);
405    if literal.is_empty() || !(text.height > 1e-12) {
406        return String::new();
407    }
408    let size = (text.height / CAP_PER_EM) * PT_PER_MM;
409    let width = text_width(&text.text);
410    let (cx, cy) = point(text.anchor, h);
411    // `Tf` carries the size, so the matrix is the run's basis NORMALISED by
412    // its cap height — a rotation when the run is merely turned, the sheared
413    // pair when an oblique annotation plane projected it. The page's y runs
414    // UP and the drawing's runs DOWN, so a paper vector flips its second
415    // component on the way in.
416    let unit = |v: [f64; 2]| [v[0] / text.height, -v[1] / text.height];
417    let (along, up) = (unit(text.right), unit(text.up));
418    // Back off half the string along the baseline and half a cap height down
419    // the run's own up direction, so it is centred on the anchor either way.
420    let x = cx - (along[0] * width + up[0] * CAP_PER_EM) * size * 0.5;
421    let y = cy - (along[1] * width + up[1] * CAP_PER_EM) * size * 0.5;
422    format!(
423        "BT /F1 {size:.4} Tf {} ({literal}) Tj ET\n",
424        text_matrix(along, up, x, y)
425    )
426}
427
428/// `a b c d e f Tm` from the run's basis normalised by its cap height:
429/// `along` the baseline and `up` its own up direction.
430fn text_matrix(along: [f64; 2], up: [f64; 2], x: f64, y: f64) -> String {
431    format!(
432        "{:.6} {:.6} {:.6} {:.6} {x:.4} {y:.4} Tm",
433        along[0], along[1], up[0], up[1]
434    )
435}
436
437/// A sheet millimetre point in PDF user space: y flips about the page height.
438fn point(p: [f64; 2], height_mm: f64) -> (f64, f64) {
439    (p[0] * PT_PER_MM, (height_mm - p[1]) * PT_PER_MM)
440}
441
442// ---------------------------------------------------------------------------
443// Text: WinAnsi encoding and Helvetica metrics
444// ---------------------------------------------------------------------------
445
446/// `text` as a PDF string literal's CONTENTS (no parentheses): WinAnsi bytes,
447/// with `(`, `)` and `\` escaped and everything outside printable ASCII written
448/// as a three-digit octal escape, so a content stream stays readable ASCII.
449pub fn string_literal(text: &str) -> String {
450    let mut out = String::new();
451    for byte in encode_winansi(text) {
452        match byte {
453            b'(' | b')' | b'\\' => {
454                out.push('\\');
455                out.push(byte as char);
456            }
457            0x20..=0x7e => out.push(byte as char),
458            _ => out.push_str(&format!("\\{byte:03o}")),
459        }
460    }
461    out
462}
463
464/// `text` in WinAnsi (CP1252) bytes. A character the encoding has no glyph for
465/// becomes `?` — see this module's header for what that costs.
466pub fn encode_winansi(text: &str) -> Vec<u8> {
467    text.chars().map(|ch| winansi(ch).unwrap_or(b'?')).collect()
468}
469
470/// The 0x80-0x9f band of WinAnsi (CP1252), the one place it differs from ISO
471/// 8859-1: `(character, byte)`.
472const CP1252_BAND: [(char, u8); 27] = [
473    ('\u{20ac}', 0x80), // euro
474    ('\u{201a}', 0x82),
475    ('\u{192}', 0x83),
476    ('\u{201e}', 0x84),
477    ('\u{2026}', 0x85), // ellipsis
478    ('\u{2020}', 0x86),
479    ('\u{2021}', 0x87),
480    ('\u{2c6}', 0x88),
481    ('\u{2030}', 0x89),
482    ('\u{160}', 0x8a),
483    ('\u{2039}', 0x8b),
484    ('\u{152}', 0x8c),
485    ('\u{17d}', 0x8e),
486    ('\u{2018}', 0x91),
487    ('\u{2019}', 0x92),
488    ('\u{201c}', 0x93),
489    ('\u{201d}', 0x94),
490    ('\u{2022}', 0x95), // bullet
491    ('\u{2013}', 0x96), // en dash
492    ('\u{2014}', 0x97), // em dash
493    ('\u{2dc}', 0x98),
494    ('\u{2122}', 0x99), // trade mark
495    ('\u{161}', 0x9a),
496    ('\u{203a}', 0x9b),
497    ('\u{153}', 0x9c),
498    ('\u{17e}', 0x9e),
499    ('\u{178}', 0x9f),
500];
501
502/// One character's WinAnsi code point.
503fn winansi(ch: char) -> Option<u8> {
504    if let Some((_, byte)) = CP1252_BAND.iter().find(|(band, _)| *band == ch) {
505        return Some(*byte);
506    }
507    Some(match ch {
508        // ASCII and the Latin-1 upper half are WinAnsi's own, with the 0x80-0x9f
509        // band the one place CP1252 differs from ISO 8859-1.
510        '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => ch as u8,
511        // The two substitutions a drawing needs. `⌀` U+2300 DIAMETER SIGN is
512        // what the kernel formats a diameter with and is not in WinAnsi; `Ø` is
513        // the glyph every drawing standard already accepts for it. U+2212 MINUS
514        // SIGN is the formatter's minus, and a hyphen is what Helvetica has.
515        '\u{2300}' => 0xd8,
516        '\u{2212}' => b'-',
517        _ => return None,
518    })
519}
520
521/// Every text string a PDF this module wrote shows, in page order: each
522/// `(…) Tj` literal read back from the file's own bytes — escapes undone and
523/// WinAnsi decoded, so a `⌀` the writer substituted reads `Ø` and a U+2212 a
524/// hyphen, which is what the file says. What the export door reports ABOUT the
525/// file, so a script can assert the drawing's text without a PDF reader.
526pub fn text_strings(pdf: &[u8]) -> Vec<String> {
527    let mut out = Vec::new();
528    let mut at = 0;
529    while let Some(open) = pdf[at..].iter().position(|b| *b == b'(').map(|i| at + i) {
530        let mut bytes = Vec::new();
531        let mut i = open + 1;
532        while i < pdf.len() && pdf[i] != b')' {
533            if pdf[i] == b'\\' && i + 1 < pdf.len() {
534                let digits: Vec<u8> = pdf[i + 1..].iter().take(3).take_while(|b| (b'0'..=b'7').contains(b)).copied().collect();
535                if digits.is_empty() {
536                    bytes.push(pdf[i + 1]);
537                    i += 2;
538                } else {
539                    bytes.push(digits.iter().fold(0u32, |n, d| n * 8 + (d - b'0') as u32) as u8);
540                    i += 1 + digits.len();
541                }
542            } else {
543                bytes.push(pdf[i]);
544                i += 1;
545            }
546        }
547        at = i + 1;
548        if pdf[at.min(pdf.len())..].starts_with(b" Tj") {
549            out.push(bytes.iter().map(|byte| from_winansi(*byte)).collect());
550        }
551    }
552    out
553}
554
555/// One WinAnsi byte's character.
556fn from_winansi(byte: u8) -> char {
557    CP1252_BAND.iter().find(|(_, band)| *band == byte).map(|(ch, _)| *ch).unwrap_or(byte as char)
558}
559
560/// A string's advance in ems, from Helvetica's own metrics.
561pub fn text_width(text: &str) -> f64 {
562    encode_winansi(text).iter().map(|byte| advance(*byte)).sum::<f64>() / 1000.0
563}
564
565/// One WinAnsi byte's Helvetica advance in 1/1000 em. The printable ASCII range
566/// is the AFM's; the accented Latin-1 letters take their base letter's, which
567/// they share in this face, and anything else takes the average — a width is
568/// only used to CENTRE a run, so being a few thousandths out moves a title
569/// block's text by less than a stroke.
570fn advance(byte: u8) -> f64 {
571    const ASCII: [u16; 95] = [
572        278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // 32..47
573        556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // 48..63
574        1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // 64..79
575        667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 80..95
576        333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // 96..111
577        556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, // 112..126
578    ];
579    match byte {
580        0x20..=0x7e => f64::from(ASCII[(byte - 0x20) as usize]),
581        0xb0 => 400.0, // degree
582        0xb1 => 584.0, // plus-minus
583        0xd7 => 584.0, // multiply
584        0xd8 => 778.0, // O with stroke — the diameter sign
585        0xa0 => 278.0, // no-break space
586        // The accented capitals, which carry their base letter's width in this
587        // face; everything else (the accented lower case, the symbols) takes the
588        // average, which only moves a centred run by a fraction of a stroke.
589        0xc0..=0xdd => 722.0,
590        _ => 556.0,
591    }
592}
593
594// ---------------------------------------------------------------------------
595// The file
596// ---------------------------------------------------------------------------
597
598/// The bytes, and where each object started — the xref table IS those offsets,
599/// so they are recorded as the objects are written rather than counted twice.
600#[derive(Default)]
601struct Writer {
602    out: Vec<u8>,
603    /// `offsets[n - 1]` is object `n`'s byte offset.
604    offsets: Vec<usize>,
605}
606
607impl Writer {
608    fn header(&mut self, version: &str) {
609        self.out.extend_from_slice(format!("%PDF-{version}\n").as_bytes());
610        // No binary marker comment after it: this writer emits no byte above
611        // 0x7e (every string literal is octal-escaped and no stream is
612        // compressed), so the whole file is ASCII and the marker would be the
613        // only thing in it claiming otherwise.
614    }
615
616    fn begin(&mut self, number: usize) {
617        assert_eq!(number, self.offsets.len() + 1, "objects are written in order");
618        self.offsets.push(self.out.len());
619        self.out.extend_from_slice(format!("{number} 0 obj\n").as_bytes());
620    }
621
622    fn object(&mut self, number: usize, body: &str) {
623        self.begin(number);
624        self.out.extend_from_slice(body.as_bytes());
625        self.out.extend_from_slice(b"\nendobj\n");
626    }
627
628    fn stream(&mut self, number: usize, content: &[u8]) {
629        self.begin(number);
630        self.out
631            .extend_from_slice(format!("<< /Length {} >>\nstream\n", content.len()).as_bytes());
632        self.out.extend_from_slice(content);
633        self.out.extend_from_slice(b"endstream\nendobj\n");
634    }
635
636    /// The cross-reference table and the trailer. Entries are exactly twenty
637    /// bytes, free object 0 first, which is what lets a reader seek by object
638    /// number.
639    fn xref_and_trailer(&mut self, root: usize) {
640        let start = self.out.len();
641        let count = self.offsets.len() + 1;
642        self.out.extend_from_slice(format!("xref\n0 {count}\n").as_bytes());
643        self.out.extend_from_slice(b"0000000000 65535 f \n");
644        for offset in &self.offsets {
645            self.out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
646        }
647        self.out.extend_from_slice(
648            format!("trailer\n<< /Size {count} /Root {root} 0 R /Info 4 0 R >>\nstartxref\n{start}\n%%EOF\n")
649                .as_bytes(),
650        );
651    }
652}
653
654
655
656