Skip to main content

blockworx_export/
pdf.rs

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