Skip to main content

blockworx/export/
pdf.rs

1//! The whole document as a PDF (D21): one page per scope, the navigation
2//! hierarchy as the PDF outline, and a link annotation over every block that
3//! opens a scope — so the diagram navigates inside any ordinary PDF reader,
4//! and review rides that reader's own annotation tools rather than anything
5//! the app ships.
6//!
7//! Pages are drawn by the existing SVG export path
8//! ([`crate::export::level::render_level`]) and placed with `krilla-svg`.
9//! Nothing here knows how a block looks; a second renderer would drift from
10//! the editor's, and the editor's is the one users check their drawings
11//! against.
12
13use crate::canvas::convert::IntoEgui as _;
14use blockworx_doc::{document::IndexedDocument, id::BlockId};
15use blockworx_geom::{Rect, Vec2, pos2};
16use blockworx_paint::Luminance;
17use blockworx_paint::{Color, Font};
18
19use krilla::{
20    Document as Pdf, SerializeSettings,
21    annotation::{Annotation, LinkAnnotation, Target},
22    color::rgb,
23    destination::XyzDestination,
24    geom::{PathBuilder, Point, Rect as PdfRect, Size, Transform},
25    metadata::Metadata,
26    num::NormalizedF32,
27    outline::{Outline, OutlineNode},
28    page::PageSettings,
29    paint::{Fill, Stroke},
30    text::{Font as PdfFont, TextDirection},
31};
32use krilla_svg::{SurfaceExt, SvgSettings};
33
34use blockworx_store::projection::Provenance;
35
36use crate::{
37    path::{BlockPath, child_blocks},
38    theme::Role,
39    tools::title_block::Cell,
40};
41
42/// One world-unit-to-point ratio for the whole document — the CSS pt/px
43/// convention — so a block is the same physical size on every page and the
44/// *pages* differ instead of the scale. Fit-to-page onto one uniform sheet
45/// was tried first and reversed (2026-08-30, user feedback): it blew small
46/// scopes up and shrank large ones down. Mixed page sizes are ordinary PDF
47/// (readers, annotators, and printers all take per-page media boxes).
48const SCALE: f32 = 0.75;
49/// Page margin, half an inch.
50const MARGIN: f32 = 36.0;
51/// The smallest sheet cut, 4×3 in: a two-block scope gets a small page,
52/// not a postage stamp under a block wider than the drawing.
53const MIN_PAGE: Vec2 = Vec2::new(288.0, 216.0);
54/// The largest page dimension common readers accept (Acrobat's 200-inch
55/// limit), in points. A scope that would overflow it is scaled down to
56/// fit — the one case where the document-wide scale gives way.
57const MAX_PAGE: f32 = 14_400.0;
58/// The tallest the title block gets: its two-column head, then the path and
59/// the provenance line beneath it. What a page reserves under its drawing.
60const BLOCK_ROWS: f32 = 4.0;
61/// Space between one of the block's lines and the next.
62const LEADING: f32 = 4.0;
63/// Breathing room between the title block's lines and the rule around them.
64const BLOCK_PAD: f32 = 3.0;
65/// The gutter between the head's two columns, and between a caption and
66/// what it captions.
67const COLUMN_GAP: f32 = 12.0;
68const CAPTION_GAP: f32 = 4.0;
69
70/// The one size the printed title block sets everything in: the canvas's own
71/// block-title size at the document scale, so the block reads at the size of
72/// the titles in the drawing above it.
73#[derive(Clone, Copy, PartialEq, Debug)]
74pub struct TextSize(f32);
75
76impl TextSize {
77    pub fn of(theme: &crate::theme::Theme) -> TextSize {
78        TextSize(crate::tools::title_block::text_size(theme) * SCALE)
79    }
80
81    fn get(self) -> f32 {
82        self.0
83    }
84
85    /// The pitch between the block's lines.
86    fn line(self) -> f32 {
87        self.0 + LEADING
88    }
89
90    /// The band a page keeps clear under its drawing for the block.
91    fn footer(self) -> f32 {
92        BLOCK_ROWS * self.line() + 2.0 * BLOCK_PAD
93    }
94
95    fn font(self) -> Font {
96        Font::canvas(self.0)
97    }
98}
99/// The rule's corner radius and weight.
100const BLOCK_RADIUS: f32 = 3.0;
101const BLOCK_BORDER: f32 = 0.6;
102
103/// What a PDF export is taken from.
104pub struct Scene<'a> {
105    pub document: IndexedDocument<'a>,
106    /// The session's theme. Its role→base table is kept and only the palette
107    /// is swapped, so a user's role edits survive into print.
108    pub theme: &'a crate::theme::Theme,
109    /// The palette family to print in, at [`Luminance::Light`].
110    pub scheme: blockworx_paint::Scheme,
111    pub font: blockworx_paint::FontChoice,
112    /// What every page's title block states — the same statement, from the
113    /// same definition, that the editor's block draws.
114    pub block: crate::tools::title_block::TitleBlock,
115    /// This export's own stamp, for the PDF's document information. Not the
116    /// block's `from`, which says where the *document* came from.
117    pub provenance: Option<Provenance>,
118}
119
120/// How a scope's world-space `frame` is placed on its page: at a given
121/// scale, centred in the content box. Page coordinates are krilla's —
122/// points, y down from the page's top-left corner.
123#[derive(Clone, Copy, PartialEq, Debug)]
124pub struct Fit {
125    scale: f32,
126    /// Page-point translation applied after scaling.
127    offset: Vec2,
128}
129
130impl Fit {
131    /// `frame` placed in `content` at `scale`, centred. The scale is the
132    /// document's, not the page's, so nothing here can stretch or shrink a
133    /// drawing to its sheet.
134    pub fn at(frame: Rect, content: Rect, scale: f32) -> Fit {
135        let centring = (content.size() - frame.size() * scale) / 2.0;
136        Fit {
137            scale,
138            offset: content.min.to_vec2() + centring - frame.min.to_vec2() * scale,
139        }
140    }
141
142    /// Where a world-space rect lands on the page.
143    pub fn place(self, world: Rect) -> Rect {
144        Rect::from_min_max(
145            (world.min.to_vec2() * self.scale + self.offset).to_pos2(),
146            (world.max.to_vec2() * self.scale + self.offset).to_pos2(),
147        )
148    }
149}
150
151/// One scope's sheet: cut to the drawing it holds — the scope's extent at
152/// the document scale, plus margins and the title block's band, floored and
153/// capped — and the placement of that drawing on it.
154pub struct Sheet {
155    pub size: Vec2,
156    pub fit: Fit,
157    footer: f32,
158}
159
160impl Sheet {
161    /// The sheet cut for a scope whose drawing spans `frame`, under a title
162    /// block set at `text`.
163    pub fn for_frame(frame: Rect, text: TextSize) -> Sheet {
164        // An empty scope renders to no rect at all; it gets the floor sheet.
165        let frame = if frame.is_finite() && frame.is_positive() {
166            frame
167        } else {
168            Rect::ZERO
169        };
170        let footer = text.footer();
171        let chrome = Vec2::new(2.0 * MARGIN, 2.0 * MARGIN + footer);
172        let scale = [
173            (MAX_PAGE - chrome.x) / frame.width(),
174            (MAX_PAGE - chrome.y) / frame.height(),
175        ]
176        .into_iter()
177        .filter(|s| s.is_finite() && *s > 0.0)
178        .fold(SCALE, f32::min);
179        let size = (frame.size() * scale + chrome).max(MIN_PAGE);
180        Sheet {
181            size,
182            fit: Fit::at(frame, content_box(size, footer), scale),
183            footer,
184        }
185    }
186
187    /// The band the drawing is placed in: the sheet less its margins and the
188    /// title block below.
189    #[cfg_attr(not(test), allow(dead_code))]
190    fn content_box(&self) -> Rect {
191        content_box(self.size, self.footer)
192    }
193}
194
195/// The band a sheet of `size` places its drawing in.
196fn content_box(size: Vec2, footer: f32) -> Rect {
197    Rect::from_min_max(
198        pos2(MARGIN, MARGIN),
199        pos2(size.x - MARGIN, size.y - MARGIN - footer),
200    )
201}
202
203/// A scope and the scopes it holds: the navigator's hierarchy, with a page
204/// number attached to each node.
205struct Scoped {
206    path: BlockPath,
207    title: String,
208    page: usize,
209    /// Child scopes, in [`child_blocks`] order — the navigator's order.
210    children: Vec<Scoped>,
211}
212
213impl Scoped {
214    /// Pre-order, which is the order pages were numbered in, so the nth
215    /// element is the nth page.
216    fn flatten<'a>(&'a self, out: &mut Vec<&'a Scoped>) {
217        out.push(self);
218        for child in &self.children {
219            child.flatten(out);
220        }
221    }
222
223    fn outline_node(&self) -> OutlineNode {
224        let mut node = OutlineNode::new(self.title.clone(), destination(self.page)).with_open(true);
225        for child in &self.children {
226            node.push_child(child.outline_node());
227        }
228        node
229    }
230}
231
232/// The top of a page, which is where a jump to a scope should land.
233fn destination(page: usize) -> XyzDestination {
234    XyzDestination::new(page, Point::from_xy(0.0, 0.0))
235}
236
237/// A scope's heading: the block's title, or `untitled block` where it has
238/// none.
239fn scope_title(document: &IndexedDocument<'_>, id: BlockId) -> String {
240    let name = document
241        .doc
242        .block(&id)
243        .map(|block| block.title.name.clone());
244    crate::edit::describe::bare("block", name)
245}
246
247/// Walk the navigation hierarchy from the document's designated top,
248/// numbering pages depth-first as the navigator lists them.
249fn plan(document: &IndexedDocument<'_>, name: String) -> Scoped {
250    fn descend(
251        document: &IndexedDocument<'_>,
252        path: BlockPath,
253        title: String,
254        next: &mut usize,
255    ) -> Scoped {
256        let page = *next;
257        *next += 1;
258        let children = child_blocks(document, path.scope())
259            .into_iter()
260            .filter(|&id| crate::path::structure(document, id).opens_a_scope())
261            .map(|id| {
262                let mut child = path.clone();
263                child.push(id);
264                descend(document, child, scope_title(document, id), next)
265            })
266            .collect();
267        Scoped {
268            path,
269            title,
270            page,
271            children,
272        }
273    }
274    descend(document, BlockPath::opening(document.doc), name, &mut 0)
275}
276
277/// Every scope this export cuts a page for, in page order — the plan's own
278/// answer, for the test that holds the PDF's pages, the navigator's branches
279/// and the canvas's double borders to one predicate.
280#[cfg(test)]
281pub(crate) fn page_scopes(document: &IndexedDocument<'_>) -> Vec<crate::path::Scope> {
282    let planned = plan(document, "drawing".to_owned());
283    let mut order = Vec::new();
284    planned.flatten(&mut order);
285    order.iter().map(|scoped| scoped.path.scope()).collect()
286}
287
288/// Render `scene` to PDF bytes: one page per scope, outlined and cross-linked.
289pub fn export(scene: Scene<'_>) -> anyhow::Result<Vec<u8>> {
290    let Scene {
291        document,
292        theme,
293        scheme,
294        font,
295        block,
296        provenance,
297    } = scene;
298
299    let mut theme = theme.clone();
300    theme.set_palette(scheme.palette(Luminance::Light));
301    let sheet_font = PdfFont::new(font.bytes().into(), 0);
302    let text = TextSize::of(&theme);
303
304    let root = plan(&document, block.name.clone());
305    let mut order = Vec::new();
306    root.flatten(&mut order);
307
308    // epaint measures what krilla draws — in the same family, so the widths
309    // the block is laid out from are the widths the glyphs take. The
310    // toolkit's own layout is the one source of text extents (CLAUDE.md —
311    // consume it, don't approximate it).
312    let mut fonts = egui::epaint::text::Fonts::new(
313        egui::epaint::text::TextOptions::default(),
314        crate::canvas::build_fonts(font),
315    );
316    let mut pdf = Pdf::new_with(SerializeSettings::default());
317    for (index, scope) in order.iter().enumerate() {
318        draw_page(
319            &mut pdf,
320            &Page {
321                document: &document,
322                theme: &theme,
323                font,
324                sheet_font: sheet_font.as_ref(),
325                text,
326                scope,
327                block: &block,
328                ancestors: ancestors_of(&order, index),
329            },
330            &mut fonts,
331        )?;
332    }
333
334    let mut outline = Outline::new();
335    outline.push_child(root.outline_node());
336    pdf.set_outline(outline);
337    pdf.set_metadata(metadata(&block.name, provenance.as_ref()));
338
339    Ok(pdf.finish()?)
340}
341
342/// The page of every scope `order[index]` hangs from, outermost first — one
343/// per ancestor segment of its path, which is what the title block's Path row
344/// links each of its own segments to. Pre-order puts every ancestor before
345/// its descendants, so the search only ever looks backwards.
346fn ancestors_of(order: &[&Scoped], index: usize) -> Vec<usize> {
347    let path = order[index].path.segments();
348    (1..path.len())
349        .filter_map(|depth| {
350            order[..index]
351                .iter()
352                .rev()
353                .find(|scope| scope.path.segments() == &path[..depth])
354                .map(|scope| scope.page)
355        })
356        .collect()
357}
358
359/// Everything one page is drawn from.
360struct Page<'a> {
361    document: &'a IndexedDocument<'a>,
362    theme: &'a crate::theme::Theme,
363    font: blockworx_paint::FontChoice,
364    /// The typeface the sheet's own text is set in — the drawing's, so the
365    /// title block matches the titles above it.
366    sheet_font: Option<&'a PdfFont>,
367    text: TextSize,
368    scope: &'a Scoped,
369    /// What every page's title block states.
370    block: &'a crate::tools::title_block::TitleBlock,
371    /// The page each ancestor segment of this scope's path opens, outermost
372    /// first. The last segment is this page itself and gets no link.
373    ancestors: Vec<usize>,
374}
375
376fn draw_page(
377    pdf: &mut Pdf,
378    page: &Page<'_>,
379    fonts: &mut egui::epaint::text::Fonts,
380) -> anyhow::Result<()> {
381    let mut presentation = crate::presentation::Presentation::default();
382    let level = crate::export::level::render_level(
383        page.theme,
384        page.font,
385        *page.document,
386        &page.scope.path,
387        &mut presentation,
388    );
389    let tree = usvg::Tree::from_str(&level.svg, &usvg::Options::default())?;
390    let sheet = Sheet::for_frame(level.frame, page.text);
391    let fit = sheet.fit;
392
393    let size = Size::from_wh(sheet.size.x, sheet.size.y)
394        .ok_or_else(|| anyhow::anyhow!("the page size is degenerate"))?;
395    let mut pdf_page = pdf.start_page_with(PageSettings::new(size));
396    let mut printed = None;
397    {
398        let mut surface = pdf_page.surface();
399
400        let mut ground = PathBuilder::new();
401        if let Some(rect) = PdfRect::from_xywh(0.0, 0.0, sheet.size.x, sheet.size.y) {
402            ground.push_rect(rect);
403        }
404        if let Some(path) = ground.finish() {
405            surface.set_fill(Some(fill(page.theme, Role::CanvasBackground)));
406            surface.draw_path(&path);
407        }
408
409        if let Some(font) = page.sheet_font {
410            printed = draw_title_block(&mut surface, fonts, page, &sheet, font);
411        }
412
413        let origin = fit.place(level.frame).min;
414        surface.push_transform(&Transform::from_row(
415            fit.scale, 0.0, 0.0, fit.scale, origin.x, origin.y,
416        ));
417        let svg_size = Size::from_wh(tree.size().width(), tree.size().height())
418            .ok_or_else(|| anyhow::anyhow!("the rendered scope has no extent"))?;
419        surface.draw_svg(&tree, svg_size, SvgSettings::default());
420        surface.pop();
421        surface.finish();
422    }
423
424    for child in &page.scope.children {
425        let Some(id) = child.path.scope().block() else {
426            continue;
427        };
428        let Some(world) = level
429            .blocks
430            .iter()
431            .find(|&&(block, _)| block == id)
432            .map(|&(_, rect)| rect)
433        else {
434            continue;
435        };
436        if let Some(link) = link(fit.place(world), child.page, &child.title) {
437            pdf_page.add_annotation(link);
438        }
439    }
440    // Every scope this page hangs from is reachable from the Path row it is
441    // named in — better than one strip back to the parent, which could only
442    // ever offer the step above.
443    for (rect, target, name) in printed.into_iter().flat_map(|p| p.links) {
444        if let Some(link) = link(rect, target, &name) {
445            pdf_page.add_annotation(link);
446        }
447    }
448    pdf_page.finish();
449    Ok(())
450}
451
452/// What printing the title block leaves behind for the page to place: the
453/// rule around it, and a link over each ancestor segment of its Path row.
454struct Printed {
455    #[cfg_attr(not(test), allow(dead_code))]
456    frame: Rect,
457    links: Vec<(Rect, usize, String)>,
458}
459
460/// The title block, in the corner an ECAD sheet keeps it, from the one
461/// definition the editor's block is also drawn from
462/// ([`crate::tools::title_block::Grid`]): a two-column head over the path and
463/// whatever else runs wide, anchored to the lower-right margin from epaint's
464/// measured widths and ruled like a sheet's block.
465///
466/// The path's ancestor segments carry link annotations to their own pages, so
467/// every scope this one hangs from is one click away from the row that names
468/// it — which is what retired the heading strip that used to offer the step
469/// above and nothing else.
470fn draw_title_block(
471    surface: &mut krilla::surface::Surface<'_>,
472    fonts: &mut egui::epaint::text::Fonts,
473    page: &Page<'_>,
474    sheet: &Sheet,
475    font: &PdfFont,
476) -> Option<Printed> {
477    let mut text = Measured {
478        fonts,
479        font: page.text.font(),
480    };
481    let segments = crate::tools::content_path::segments(page.document, &page.scope.path);
482    let grid = page.block.grid(&segments);
483
484    // What the sheet leaves the block, and how that is shared out: a head
485    // column gets half of it where there are two, and a wide line all of it.
486    let inner = sheet.size.x - 2.0 * (MARGIN + BLOCK_PAD);
487    let share = if grid.right.is_empty() {
488        inner
489    } else {
490        (inner - COLUMN_GAP) / 2.0
491    };
492    let left = Column::lay_out(&mut text, &grid.left, share, Fitting::ByCharacter);
493    let right = Column::lay_out(&mut text, &grid.right, share, Fitting::ByCharacter);
494    let wide = Column::lay_out(&mut text, &grid.wide, inner, Fitting::BySegment);
495
496    let head_width = if right.cells.is_empty() {
497        left.width
498    } else {
499        left.width + COLUMN_GAP + right.width
500    };
501    let width = head_width.max(wide.width);
502    let rows = grid.head_rows() + grid.wide.len();
503    let line = page.text.line();
504    let (ascent, line_box) = (text.ascent(), text.height());
505    let height = (rows.saturating_sub(1) as f32) * line + line_box;
506
507    let block = Rect::from_min_max(
508        pos2(
509            sheet.size.x - MARGIN - BLOCK_PAD - width,
510            sheet.size.y - MARGIN - BLOCK_PAD - height,
511        ),
512        pos2(
513            sheet.size.x - MARGIN - BLOCK_PAD,
514            sheet.size.y - MARGIN - BLOCK_PAD,
515        ),
516    );
517    let baseline = |row: usize| block.min.y + row as f32 * line + ascent;
518
519    let mut links = Vec::new();
520    for (row, cell) in left.cells.iter().enumerate() {
521        cell.draw(
522            surface,
523            page,
524            font,
525            At {
526                x: block.min.x,
527                baseline: baseline(row),
528                captions: left.captions,
529            },
530        );
531    }
532    for (row, cell) in right.cells.iter().enumerate() {
533        cell.draw(
534            surface,
535            page,
536            font,
537            At {
538                x: block.min.x + left.width + COLUMN_GAP,
539                baseline: baseline(row),
540                captions: right.captions,
541            },
542        );
543    }
544    for (row, cell) in wide.cells.iter().enumerate() {
545        let baseline = baseline(grid.head_rows() + row);
546        let value_x = cell.draw(
547            surface,
548            page,
549            font,
550            At {
551                x: block.min.x,
552                baseline,
553                captions: wide.captions,
554            },
555        );
556        links.extend(ancestor_links(
557            &mut text,
558            page,
559            cell,
560            Placed {
561                x: value_x,
562                top: baseline - ascent,
563                height: line_box,
564            },
565        ));
566    }
567
568    surface.set_fill(None);
569    // Ruled from the block that was actually laid out, so the frame holds it
570    // rather than the band it was assumed to fill.
571    let frame = block.expand(BLOCK_PAD);
572    let path = rounded_rect(frame, BLOCK_RADIUS)?;
573    surface.set_stroke(Some(Stroke {
574        paint: paint(page.theme, Role::TitleBlockBorder),
575        width: BLOCK_BORDER,
576        ..Stroke::default()
577    }));
578    surface.draw_path(&path);
579    surface.set_stroke(None);
580    Some(Printed { frame, links })
581}
582
583/// Where one of the block's lines goes: the left edge of its caption, the
584/// baseline it sits on, and the width of the caption column beside it.
585#[derive(Clone, Copy)]
586struct At {
587    x: f32,
588    baseline: f32,
589    captions: f32,
590}
591
592/// Where a line's value was set: its left edge and the line box it fills.
593#[derive(Clone, Copy)]
594struct Placed {
595    x: f32,
596    top: f32,
597    height: f32,
598}
599
600/// A link over each ancestor segment of `cell`'s path, aimed at that
601/// ancestor's page. The last segment names this page and gets none; a segment
602/// the line had no room for was dropped, and its link with it.
603fn ancestor_links(
604    text: &mut Measured<'_>,
605    page: &Page<'_>,
606    cell: &Laid,
607    at: Placed,
608) -> Vec<(Rect, usize, String)> {
609    let Some(kept) = cell.kept.as_ref() else {
610        return Vec::new();
611    };
612    let Placed { top, height, .. } = at;
613    let mut links = Vec::new();
614    // `kept.first` is where the drawn segments start in the whole path, and
615    // `kept.elided` the ellipsis standing in for what came before them.
616    let mut x = at.x + text.width(kept.elided);
617    for (offset, segment) in kept.segments.iter().enumerate() {
618        let width = text.width(segment);
619        let index = kept.first + offset;
620        if let Some(&target) = page.ancestors.get(index) {
621            links.push((
622                Rect::from_min_max(pos2(x, top), pos2(x + width, top + height)),
623                target,
624                segment.clone(),
625            ));
626        }
627        x += width + text.width(crate::tools::content_path::SEPARATOR_STR);
628    }
629    links
630}
631
632/// A link annotation over `rect` jumping to the top of `page`.
633fn link(rect: Rect, page: usize, alt: &str) -> Option<Annotation> {
634    let rect = PdfRect::from_ltrb(rect.min.x, rect.min.y, rect.max.x, rect.max.y)?;
635    Some(Annotation::new_link(
636        LinkAnnotation::new(rect, Target::Destination(destination(page).into())),
637        Some(alt.to_owned()),
638    ))
639}
640
641/// epaint's text extents in the family and size the block is set in — the
642/// toolkit's own layout, which is what krilla then draws.
643struct Measured<'a> {
644    fonts: &'a mut egui::epaint::text::Fonts,
645    font: Font,
646}
647
648impl Measured<'_> {
649    fn galley(&mut self, text: &str) -> std::sync::Arc<egui::Galley> {
650        // Measured, never painted: the PDF writer sets its own ink, so this
651        // is egui's "recolor me" sentinel rather than a color.
652        self.fonts.with_pixels_per_point(1.0).layout_no_wrap(
653            text.to_owned(),
654            (&self.font).egui(),
655            Color::PLACEHOLDER.egui(),
656        )
657    }
658
659    fn width(&mut self, text: &str) -> f32 {
660        self.galley(text).size().x
661    }
662
663    /// The line box one run of this text occupies.
664    fn height(&mut self) -> f32 {
665        self.galley(MEASURING_RUN).size().y
666    }
667
668    /// Where a line's baseline sits below its top edge.
669    fn ascent(&mut self) -> f32 {
670        let galley = self.galley(MEASURING_RUN);
671        galley
672            .rows
673            .iter()
674            .flat_map(|row| row.glyphs.iter())
675            .next()
676            .map_or_else(|| galley.size().y, |glyph| glyph.font_ascent)
677    }
678}
679
680/// A run with an ascender and a descender, so the line box measured off it is
681/// the one every line of the block gets.
682const MEASURING_RUN: &str = "Ag";
683
684/// How a value too wide for its column gives way.
685#[derive(Clone, Copy, PartialEq, Eq)]
686enum Fitting {
687    /// Character by character from the front, an ellipsis standing in for
688    /// what went.
689    ByCharacter,
690    /// Whole path segments from the front — a half-eaten name links to
691    /// nothing and reads as nothing, so a segment is kept or dropped.
692    BySegment,
693}
694
695/// Which of a path's segments a line had room for.
696struct Kept {
697    /// Index of the first surviving segment in the whole path.
698    first: usize,
699    segments: Vec<String>,
700    /// What stands in front of them for the ones that were dropped.
701    elided: &'static str,
702}
703
704/// One cell placed on its line: what it draws, and — for a path — which of
705/// its segments survived the fit.
706struct Laid {
707    caption: &'static str,
708    value: String,
709    emphasis: crate::tools::title_block::Emphasis,
710    kept: Option<Kept>,
711}
712
713impl Laid {
714    /// Draw the cell at `x` on `baseline`, its value set clear of a caption
715    /// column `captions` wide. Returns where the value starts.
716    fn draw(
717        &self,
718        surface: &mut krilla::surface::Surface<'_>,
719        page: &Page<'_>,
720        font: &PdfFont,
721        at: At,
722    ) -> f32 {
723        use crate::tools::title_block::Emphasis;
724        let mut set = |text: &str, x: f32, role: Role| {
725            if text.is_empty() {
726                return;
727            }
728            surface.set_fill(Some(fill(page.theme, role)));
729            surface.draw_text(
730                Point::from_xy(x, at.baseline),
731                font.clone(),
732                page.text.get(),
733                text,
734                false,
735                TextDirection::Auto,
736            );
737        };
738        set(self.caption, at.x, Role::ShapeType);
739        let value_x = at.x + at.captions + CAPTION_GAP;
740        set(
741            &self.value,
742            value_x,
743            match self.emphasis {
744                Emphasis::Title => Role::ShapeTitle,
745                Emphasis::Body => Role::ShapeType,
746            },
747        );
748        value_x
749    }
750}
751
752/// One column of the block, measured: its cells as they will be drawn, how
753/// wide its captions are, and how wide the column ends up.
754struct Column {
755    cells: Vec<Laid>,
756    captions: f32,
757    width: f32,
758}
759
760impl Column {
761    fn lay_out(text: &mut Measured<'_>, cells: &[Cell], share: f32, fitting: Fitting) -> Column {
762        use crate::tools::title_block::Value;
763        let captions = cells
764            .iter()
765            .map(|cell| text.width(cell.caption))
766            .fold(0.0, f32::max);
767        let room = (share - captions - CAPTION_GAP).max(0.0);
768        let laid: Vec<Laid> = cells
769            .iter()
770            .map(|cell| match (&cell.value, fitting) {
771                (Value::Path(segments), Fitting::BySegment) => {
772                    let kept = fit_segments(text, segments, room);
773                    Laid {
774                        caption: cell.caption,
775                        value: format!(
776                            "{}{}",
777                            kept.elided,
778                            crate::tools::content_path::join(&kept.segments)
779                        ),
780                        emphasis: cell.emphasis,
781                        kept: Some(kept),
782                    }
783                }
784                (value, _) => Laid {
785                    caption: cell.caption,
786                    value: shortened(text, value.text(), room),
787                    emphasis: cell.emphasis,
788                    kept: None,
789                },
790            })
791            .collect();
792        let values = laid
793            .iter()
794            .map(|cell| text.width(&cell.value))
795            .fold(0.0, f32::max);
796        Column {
797            width: if laid.is_empty() {
798                0.0
799            } else {
800                captions + CAPTION_GAP + values
801            },
802            cells: laid,
803            captions,
804        }
805    }
806}
807
808/// The trailing run of `segments` that measures inside `width` — a path's
809/// tail is the part that names where you are — with an ellipsis for the rest.
810/// At least one segment is always kept: a path that says nothing at all is
811/// worse than one that overruns its column.
812fn fit_segments(text: &mut Measured<'_>, segments: &[String], width: f32) -> Kept {
813    for first in 0..segments.len().saturating_sub(1) {
814        let elided = if first == 0 { "" } else { ELIDED };
815        let candidate = format!(
816            "{elided}{}",
817            crate::tools::content_path::join(&segments[first..])
818        );
819        if text.width(&candidate) <= width {
820            return Kept {
821                first,
822                segments: segments[first..].to_vec(),
823                elided,
824            };
825        }
826    }
827    let first = segments.len().saturating_sub(1);
828    Kept {
829        first,
830        segments: segments[first..].to_vec(),
831        elided: if first == 0 { "" } else { ELIDED },
832    }
833}
834
835/// What stands in for the head of a path a line had no room for.
836const ELIDED: &str = "\u{2026}/";
837
838/// `text` trimmed from the *front* until it measures inside `width`.
839fn shortened(fonts: &mut Measured<'_>, text: String, width: f32) -> String {
840    if fonts.width(&text) <= width {
841        return text;
842    }
843    let mut chars = text.chars();
844    loop {
845        if chars.next().is_none() {
846            return String::from("\u{2026}");
847        }
848        let candidate = format!("\u{2026}{}", chars.as_str());
849        if fonts.width(&candidate) <= width {
850            return candidate;
851        }
852    }
853}
854
855/// The circular-arc control-point ratio: a quarter circle drawn as one cubic
856/// puts its handles this fraction of the radius from the corner.
857const KAPPA: f32 = 0.552_284_8;
858
859/// A rounded rectangle, cornered with cubics — krilla's path builder speaks
860/// segments, not primitives with radii.
861fn rounded_rect(rect: Rect, radius: f32) -> Option<krilla::geom::Path> {
862    let radius = radius.min(rect.width() / 2.0).min(rect.height() / 2.0);
863    if !rect.is_positive() || !radius.is_finite() || radius <= 0.0 {
864        return None;
865    }
866    let handle = radius * KAPPA;
867    let (l, t, r, b) = (rect.left(), rect.top(), rect.right(), rect.bottom());
868    let mut path = PathBuilder::new();
869    path.move_to(l + radius, t);
870    path.line_to(r - radius, t);
871    path.cubic_to(
872        r - radius + handle,
873        t,
874        r,
875        t + radius - handle,
876        r,
877        t + radius,
878    );
879    path.line_to(r, b - radius);
880    path.cubic_to(
881        r,
882        b - radius + handle,
883        r - radius + handle,
884        b,
885        r - radius,
886        b,
887    );
888    path.line_to(l + radius, b);
889    path.cubic_to(
890        l + radius - handle,
891        b,
892        l,
893        b - radius + handle,
894        l,
895        b - radius,
896    );
897    path.line_to(l, t + radius);
898    path.cubic_to(
899        l,
900        t + radius - handle,
901        l + radius - handle,
902        t,
903        l + radius,
904        t,
905    );
906    path.close();
907    path.finish()
908}
909
910fn paint(theme: &crate::theme::Theme, role: Role) -> krilla::paint::Paint {
911    let color = theme.resolve(role);
912    rgb::Color::new(color.r(), color.g(), color.b()).into()
913}
914
915fn fill(theme: &crate::theme::Theme, role: Role) -> Fill {
916    Fill {
917        paint: paint(theme, role),
918        opacity: NormalizedF32::ONE,
919        rule: krilla::paint::FillRule::default(),
920    }
921}
922
923/// The document information a reader shows. No creation date is written: the
924/// only clock available would make two exports of one document differ, and a
925/// byte-identical export is what the golden pins.
926fn metadata(name: &str, provenance: Option<&Provenance>) -> Metadata {
927    let metadata = Metadata::new()
928        .title(name.to_owned())
929        .creator(env!("CARGO_PKG_NAME").to_owned())
930        .document_id(name.to_owned());
931    match provenance {
932        None => metadata,
933        Some(from) => metadata
934            .authors(vec![from.author.clone()])
935            .description(format!("{} \u{2014} {}", from.line(), from.detail())),
936    }
937}
938
939#[cfg(test)]
940mod tests;