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, HIGHLIGHT_RGB, HIGHLIGHT_WIDTH_FACTOR};
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
320/// A Line art picture ([`super::project::LineArtLayer`]): the ground, the
321/// fills, the lines in the tiers' inks, the outlines and the arrows; black
322/// stroke and fill, butt caps and mitre joins again after it.
323fn line_art_marks(view: &ViewDrawing, layer: &super::project::LineArtLayer, h: f64, out: &mut String) {
324    use super::project::LineArtLayer as L;
325    let rgb = |c: [f64; 3]| format!("{:.3} {:.3} {:.3}", c[0], c[1], c[2]);
326    let [x0, y0, x1, y1] = layer.ground;
327    let (gx, gy) = point([x0, y1], h);
328    out.push_str(&format!("{} rg\n{gx:.3} {gy:.3} {:.3} {:.3} re f\n", rgb(L::GROUND), (x1 - x0) * PT_PER_MM, (y1 - y0) * PT_PER_MM));
329    out.push_str(&format!("{:.4} w 1 j\n", L::SEAM_MM * PT_PER_MM));
330    let mut tier = None;
331    for (triangle, adding) in &layer.fills {
332        if tier != Some(*adding) {
333            let c = rgb(if *adding { L::ADDING_FILL } else { L::PLACED_FILL });
334            out.push_str(&format!("{c} rg {c} RG\n"));
335            tier = Some(*adding);
336        }
337        for (index, p) in triangle.iter().enumerate() {
338            let (x, y) = point(*p, h);
339            out.push_str(&format!("{x:.3} {y:.3} {}\n", if index == 0 { "m" } else { "l" }));
340        }
341        out.push_str("h B\n");
342    }
343    let strokes = |runs: &mut dyn Iterator<Item = &Vec<[f64; 2]>>, color: [f64; 3], width: f64, out: &mut String| {
344        let mut runs = runs.peekable();
345        if runs.peek().is_none() {
346            return;
347        }
348        out.push_str(&format!("{} RG {:.4} w 1 J\n", rgb(color), width * PT_PER_MM));
349        for run in runs {
350            out.push_str(&path(run, h));
351        }
352    };
353    strokes(&mut view.edges.iter(), L::PLACED_EDGE, L::PLACED_EDGE_MM, out);
354    strokes(&mut layer.outlines.iter().filter(|(_, a)| !a).map(|(r, _)| r), L::PLACED_OUTLINE, L::PLACED_OUTLINE_MM, out);
355    strokes(&mut view.highlighted.iter(), L::INK, L::ADDING_EDGE_MM, out);
356    strokes(&mut layer.outlines.iter().filter(|(_, a)| *a).map(|(r, _)| r), L::INK, L::ADDING_OUTLINE_MM, out);
357    out.push_str(&format!("0 0 0 rg 0 0 0 RG {:.4} w 0 J\n", L::ARROW_SHAFT_MM * PT_PER_MM));
358    for (tail, tip) in &layer.arrows {
359        let Some((base, head)) = L::head(*tail, *tip) else { continue };
360        out.push_str(&path(&[*tail, base], h));
361        for (index, p) in head.iter().enumerate() {
362            let (x, y) = point(*p, h);
363            out.push_str(&format!("{x:.3} {y:.3} {}\n", if index == 0 { "m" } else { "l" }));
364        }
365        out.push_str("h f\n");
366    }
367    out.push_str("0 0 0 rg 0 0 0 RG 0 J 0 j\n");
368}
369
370fn view_marks(view: &ViewDrawing, h: f64, out: &mut String) {
371    if let Some(layer) = &view.line_art {
372        line_art_marks(view, layer, h, out);
373    } else if !view.edges.is_empty() {
374        out.push_str(&format!("{:.4} w\n", EDGE_WIDTH * PT_PER_MM));
375        for run in &view.edges {
376            out.push_str(&path(run, h));
377        }
378    }
379    if !view.highlighted.is_empty() && view.line_art.is_none() {
380        // The highlight stroke, then back to black for everything after it.
381        let [r, g, b] = HIGHLIGHT_RGB;
382        out.push_str(&format!("{r:.4} {g:.4} {b:.4} RG\n{:.4} w\n", EDGE_WIDTH * HIGHLIGHT_WIDTH_FACTOR * PT_PER_MM));
383        for run in &view.highlighted {
384            out.push_str(&path(run, h));
385        }
386        out.push_str("0 0 0 RG\n");
387    }
388    // The SECTION's hatch and caption, and the section LINES this placement
389    // carries — the SVG's own layering, so the two files read alike.
390    if let Some(section) = &view.section {
391        if !section.hatch.is_empty() {
392            out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
393            for line in &section.hatch {
394                out.push_str(&path(line, h));
395            }
396        }
397        for text in &section.texts {
398            out.push_str(&text_marks(text, h));
399        }
400    }
401    for mark in &view.section_marks {
402        if !mark.lines.is_empty() {
403            out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
404            for line in &mark.lines {
405                out.push_str(&path(line, h));
406            }
407        }
408        for text in &mark.texts {
409            out.push_str(&text_marks(text, h));
410        }
411    }
412    // A DETAIL's boundary and caption, then the detail circles drawn on this
413    // placement — again the SVG's order.
414    if let Some(detail) = &view.detail {
415        out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
416        out.push_str(&path(&detail.circle, h));
417        for text in &detail.texts {
418            out.push_str(&text_marks(text, h));
419        }
420    }
421    for mark in &view.detail_marks {
422        if !mark.lines.is_empty() {
423            out.push_str(&format!("{:.4} w\n", FRAME_WIDTH * PT_PER_MM));
424            for line in &mark.lines {
425                out.push_str(&path(line, h));
426            }
427        }
428        for text in &mark.texts {
429            out.push_str(&text_marks(text, h));
430        }
431    }
432    for annotation in &view.annotations {
433        if !annotation.lines.is_empty() {
434            out.push_str(&format!("{:.4} w\n", ANNOTATION_WIDTH * PT_PER_MM));
435            for line in &annotation.lines {
436                out.push_str(&path(line, h));
437            }
438        }
439        for text in &annotation.texts {
440            out.push_str(&text_marks(text, h));
441        }
442    }
443}
444
445/// A polyline as one stroked path: `m`, `l`…, `S`.
446fn path(points: &[[f64; 2]], h: f64) -> String {
447    let mut out = String::new();
448    for (index, p) in points.iter().enumerate() {
449        let (x, y) = point(*p, h);
450        out.push_str(&format!("{x:.4} {y:.4} {}\n", if index == 0 { "m" } else { "l" }));
451    }
452    if !out.is_empty() {
453        out.push_str("S\n");
454    }
455    out
456}
457
458/// A text run as `BT … Tm (…) Tj ET`, centred on its anchor.
459///
460/// The run's own basis becomes the text matrix. `Tf` still carries the size
461/// the cap height asks for, so `a b c d` is that basis divided by the height —
462/// a rotation for a flat or merely rotated run, and the sheared pair an
463/// oblique annotation plane projects to, which a rotation could not express.
464fn text_marks(text: &SheetText, h: f64) -> String {
465    let literal = string_literal(&text.text);
466    if literal.is_empty() || !(text.height > 1e-12) {
467        return String::new();
468    }
469    let size = (text.height / CAP_PER_EM) * PT_PER_MM;
470    let width = text_width(&text.text);
471    let (cx, cy) = point(text.anchor, h);
472    // `Tf` carries the size, so the matrix is the run's basis NORMALISED by
473    // its cap height — a rotation when the run is merely turned, the sheared
474    // pair when an oblique annotation plane projected it. The page's y runs
475    // UP and the drawing's runs DOWN, so a paper vector flips its second
476    // component on the way in.
477    let unit = |v: [f64; 2]| [v[0] / text.height, -v[1] / text.height];
478    let (along, up) = (unit(text.right), unit(text.up));
479    // Back off half the string along the baseline and half a cap height down
480    // the run's own up direction, so it is centred on the anchor either way.
481    let x = cx - (along[0] * width + up[0] * CAP_PER_EM) * size * 0.5;
482    let y = cy - (along[1] * width + up[1] * CAP_PER_EM) * size * 0.5;
483    format!(
484        "BT /F1 {size:.4} Tf {} ({literal}) Tj ET\n",
485        text_matrix(along, up, x, y)
486    )
487}
488
489/// `a b c d e f Tm` from the run's basis normalised by its cap height:
490/// `along` the baseline and `up` its own up direction.
491fn text_matrix(along: [f64; 2], up: [f64; 2], x: f64, y: f64) -> String {
492    format!(
493        "{:.6} {:.6} {:.6} {:.6} {x:.4} {y:.4} Tm",
494        along[0], along[1], up[0], up[1]
495    )
496}
497
498/// A sheet millimetre point in PDF user space: y flips about the page height.
499fn point(p: [f64; 2], height_mm: f64) -> (f64, f64) {
500    (p[0] * PT_PER_MM, (height_mm - p[1]) * PT_PER_MM)
501}
502
503// ---------------------------------------------------------------------------
504// Text: WinAnsi encoding and Helvetica metrics
505// ---------------------------------------------------------------------------
506
507/// `text` as a PDF string literal's CONTENTS (no parentheses): WinAnsi bytes,
508/// with `(`, `)` and `\` escaped and everything outside printable ASCII written
509/// as a three-digit octal escape, so a content stream stays readable ASCII.
510pub fn string_literal(text: &str) -> String {
511    let mut out = String::new();
512    for byte in encode_winansi(text) {
513        match byte {
514            b'(' | b')' | b'\\' => {
515                out.push('\\');
516                out.push(byte as char);
517            }
518            0x20..=0x7e => out.push(byte as char),
519            _ => out.push_str(&format!("\\{byte:03o}")),
520        }
521    }
522    out
523}
524
525/// `text` in WinAnsi (CP1252) bytes. A character the encoding has no glyph for
526/// becomes `?` — see this module's header for what that costs.
527pub fn encode_winansi(text: &str) -> Vec<u8> {
528    text.chars().map(|ch| winansi(ch).unwrap_or(b'?')).collect()
529}
530
531/// The 0x80-0x9f band of WinAnsi (CP1252), the one place it differs from ISO
532/// 8859-1: `(character, byte)`.
533const CP1252_BAND: [(char, u8); 27] = [
534    ('\u{20ac}', 0x80), // euro
535    ('\u{201a}', 0x82),
536    ('\u{192}', 0x83),
537    ('\u{201e}', 0x84),
538    ('\u{2026}', 0x85), // ellipsis
539    ('\u{2020}', 0x86),
540    ('\u{2021}', 0x87),
541    ('\u{2c6}', 0x88),
542    ('\u{2030}', 0x89),
543    ('\u{160}', 0x8a),
544    ('\u{2039}', 0x8b),
545    ('\u{152}', 0x8c),
546    ('\u{17d}', 0x8e),
547    ('\u{2018}', 0x91),
548    ('\u{2019}', 0x92),
549    ('\u{201c}', 0x93),
550    ('\u{201d}', 0x94),
551    ('\u{2022}', 0x95), // bullet
552    ('\u{2013}', 0x96), // en dash
553    ('\u{2014}', 0x97), // em dash
554    ('\u{2dc}', 0x98),
555    ('\u{2122}', 0x99), // trade mark
556    ('\u{161}', 0x9a),
557    ('\u{203a}', 0x9b),
558    ('\u{153}', 0x9c),
559    ('\u{17e}', 0x9e),
560    ('\u{178}', 0x9f),
561];
562
563/// One character's WinAnsi code point.
564fn winansi(ch: char) -> Option<u8> {
565    if let Some((_, byte)) = CP1252_BAND.iter().find(|(band, _)| *band == ch) {
566        return Some(*byte);
567    }
568    Some(match ch {
569        // ASCII and the Latin-1 upper half are WinAnsi's own, with the 0x80-0x9f
570        // band the one place CP1252 differs from ISO 8859-1.
571        '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => ch as u8,
572        // The two substitutions a drawing needs. `⌀` U+2300 DIAMETER SIGN is
573        // what the kernel formats a diameter with and is not in WinAnsi; `Ø` is
574        // the glyph every drawing standard already accepts for it. U+2212 MINUS
575        // SIGN is the formatter's minus, and a hyphen is what Helvetica has.
576        '\u{2300}' => 0xd8,
577        '\u{2212}' => b'-',
578        _ => return None,
579    })
580}
581
582/// Every text string a PDF this module wrote shows, in page order: each
583/// `(…) Tj` literal read back from the file's own bytes — escapes undone and
584/// WinAnsi decoded, so a `⌀` the writer substituted reads `Ø` and a U+2212 a
585/// hyphen, which is what the file says. What the export door reports ABOUT the
586/// file, so a script can assert the drawing's text without a PDF reader.
587/// The Info dictionary's `/HiddenLine` read back from the bytes: `analytic`,
588/// or `mesh: ` and the placements the mesh approximation drew (`None` when
589/// the file has no such entry).
590pub fn hidden_line_of(pdf: &[u8]) -> Option<String> {
591    let key = b"/HiddenLine (";
592    let start = pdf.windows(key.len()).position(|w| w == key)? + key.len();
593    let mut out = Vec::new();
594    let mut i = start;
595    while i < pdf.len() && pdf[i] != b')' {
596        if pdf[i] == b'\\' && i + 1 < pdf.len() {
597            out.push(pdf[i + 1]);
598            i += 2;
599        } else {
600            out.push(pdf[i]);
601            i += 1;
602        }
603    }
604    Some(out.iter().map(|byte| from_winansi(*byte)).collect())
605}
606
607pub fn text_strings(pdf: &[u8]) -> Vec<String> {
608    let mut out = Vec::new();
609    let mut at = 0;
610    while let Some(open) = pdf[at..].iter().position(|b| *b == b'(').map(|i| at + i) {
611        let mut bytes = Vec::new();
612        let mut i = open + 1;
613        while i < pdf.len() && pdf[i] != b')' {
614            if pdf[i] == b'\\' && i + 1 < pdf.len() {
615                let digits: Vec<u8> = pdf[i + 1..].iter().take(3).take_while(|b| (b'0'..=b'7').contains(b)).copied().collect();
616                if digits.is_empty() {
617                    bytes.push(pdf[i + 1]);
618                    i += 2;
619                } else {
620                    bytes.push(digits.iter().fold(0u32, |n, d| n * 8 + (d - b'0') as u32) as u8);
621                    i += 1 + digits.len();
622                }
623            } else {
624                bytes.push(pdf[i]);
625                i += 1;
626            }
627        }
628        at = i + 1;
629        if pdf[at.min(pdf.len())..].starts_with(b" Tj") {
630            out.push(bytes.iter().map(|byte| from_winansi(*byte)).collect());
631        }
632    }
633    out
634}
635
636/// One WinAnsi byte's character.
637fn from_winansi(byte: u8) -> char {
638    CP1252_BAND.iter().find(|(_, band)| *band == byte).map(|(ch, _)| *ch).unwrap_or(byte as char)
639}
640
641/// A string's advance in ems, from Helvetica's own metrics.
642pub fn text_width(text: &str) -> f64 {
643    encode_winansi(text).iter().map(|byte| advance(*byte)).sum::<f64>() / 1000.0
644}
645
646/// One WinAnsi byte's Helvetica advance in 1/1000 em. The printable ASCII range
647/// is the AFM's; the accented Latin-1 letters take their base letter's, which
648/// they share in this face, and anything else takes the average — a width is
649/// only used to CENTRE a run, so being a few thousandths out moves a title
650/// block's text by less than a stroke.
651fn advance(byte: u8) -> f64 {
652    const ASCII: [u16; 95] = [
653        278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // 32..47
654        556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // 48..63
655        1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // 64..79
656        667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 80..95
657        333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // 96..111
658        556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, // 112..126
659    ];
660    match byte {
661        0x20..=0x7e => f64::from(ASCII[(byte - 0x20) as usize]),
662        0xb0 => 400.0, // degree
663        0xb1 => 584.0, // plus-minus
664        0xd7 => 584.0, // multiply
665        0xd8 => 778.0, // O with stroke — the diameter sign
666        0xa0 => 278.0, // no-break space
667        // The accented capitals, which carry their base letter's width in this
668        // face; everything else (the accented lower case, the symbols) takes the
669        // average, which only moves a centred run by a fraction of a stroke.
670        0xc0..=0xdd => 722.0,
671        _ => 556.0,
672    }
673}
674
675// ---------------------------------------------------------------------------
676// The file
677// ---------------------------------------------------------------------------
678
679/// The bytes, and where each object started — the xref table IS those offsets,
680/// so they are recorded as the objects are written rather than counted twice.
681#[derive(Default)]
682struct Writer {
683    out: Vec<u8>,
684    /// `offsets[n - 1]` is object `n`'s byte offset.
685    offsets: Vec<usize>,
686}
687
688impl Writer {
689    fn header(&mut self, version: &str) {
690        self.out.extend_from_slice(format!("%PDF-{version}\n").as_bytes());
691        // No binary marker comment after it: this writer emits no byte above
692        // 0x7e (every string literal is octal-escaped and no stream is
693        // compressed), so the whole file is ASCII and the marker would be the
694        // only thing in it claiming otherwise.
695    }
696
697    fn begin(&mut self, number: usize) {
698        assert_eq!(number, self.offsets.len() + 1, "objects are written in order");
699        self.offsets.push(self.out.len());
700        self.out.extend_from_slice(format!("{number} 0 obj\n").as_bytes());
701    }
702
703    fn object(&mut self, number: usize, body: &str) {
704        self.begin(number);
705        self.out.extend_from_slice(body.as_bytes());
706        self.out.extend_from_slice(b"\nendobj\n");
707    }
708
709    fn stream(&mut self, number: usize, content: &[u8]) {
710        self.begin(number);
711        self.out
712            .extend_from_slice(format!("<< /Length {} >>\nstream\n", content.len()).as_bytes());
713        self.out.extend_from_slice(content);
714        self.out.extend_from_slice(b"endstream\nendobj\n");
715    }
716
717    /// The cross-reference table and the trailer. Entries are exactly twenty
718    /// bytes, free object 0 first, which is what lets a reader seek by object
719    /// number.
720    fn xref_and_trailer(&mut self, root: usize) {
721        let start = self.out.len();
722        let count = self.offsets.len() + 1;
723        self.out.extend_from_slice(format!("xref\n0 {count}\n").as_bytes());
724        self.out.extend_from_slice(b"0000000000 65535 f \n");
725        for offset in &self.offsets {
726            self.out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
727        }
728        self.out.extend_from_slice(
729            format!("trailer\n<< /Size {count} /Root {root} 0 R /Info 4 0 R >>\nstartxref\n{start}\n%%EOF\n")
730                .as_bytes(),
731        );
732    }
733}
734
735
736
737