1use 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
40const SCALE: f32 = 0.75;
47const MARGIN: f32 = 36.0;
49const CLEARANCE: f32 = 36.0;
52const MIN_PAGE: Vec2 = Vec2::new(288.0, 216.0);
55const MAX_PAGE: f32 = 14_400.0;
59const BLOCK_ROWS: f32 = 4.0;
62const LEADING: f32 = 4.0;
64const BLOCK_PAD: f32 = 3.0;
66const COLUMN_GAP: f32 = 12.0;
69const CAPTION_GAP: f32 = 4.0;
70
71#[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 fn line(self) -> f32 {
88 self.0 + LEADING
89 }
90
91 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}
100const BLOCK_RADIUS: f32 = 3.0;
102const BLOCK_BORDER: f32 = 0.6;
103
104pub struct Scene<'a> {
106 pub document: IndexedDocument<'a>,
107 pub theme: &'a Theme,
110 pub scheme: blockworx_paint::Scheme,
112 pub layout: &'a dyn TextLayout,
115 pub block: blockworx_editor::title_block::TitleBlock,
118 pub provenance: Option<Provenance>,
121}
122
123#[derive(Clone, Copy, PartialEq, Debug)]
127pub struct Fit {
128 scale: f32,
129 offset: Vec2,
131}
132
133impl Fit {
134 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 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
154pub struct Sheet {
158 pub size: Vec2,
159 pub fit: Fit,
160 footer: f32,
161}
162
163impl Sheet {
164 pub fn for_frame(frame: Rect, text: TextSize) -> Sheet {
167 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 #[cfg_attr(not(test), allow(dead_code))]
193 fn content_box(&self) -> Rect {
194 content_box(self.size, self.footer)
195 }
196}
197
198fn 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
206struct Scoped {
209 path: BlockPath,
210 title: String,
211 page: usize,
212 children: Vec<Scoped>,
214}
215
216impl Scoped {
217 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
235fn destination(page: usize) -> XyzDestination {
237 XyzDestination::new(page, Point::from_xy(0.0, 0.0))
238}
239
240fn 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
250fn 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
280pub 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
290pub 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
335fn 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
352struct Page<'a> {
354 document: &'a IndexedDocument<'a>,
355 theme: &'a Theme,
356 layout: &'a dyn TextLayout,
359 sheet_font: Option<&'a PdfFont>,
362 text: TextSize,
363 scope: &'a Scoped,
364 block: &'a blockworx_editor::title_block::TitleBlock,
366 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 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
443struct Printed {
446 #[cfg_attr(not(test), allow(dead_code))]
447 frame: Rect,
448 links: Vec<(Rect, usize, String)>,
449}
450
451fn 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 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 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#[derive(Clone, Copy)]
575struct At {
576 x: f32,
577 baseline: f32,
578 captions: f32,
579}
580
581#[derive(Clone, Copy)]
583struct Placed {
584 x: f32,
585 top: f32,
586 height: f32,
587}
588
589fn 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 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
621fn 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
630struct 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 fn height(&self) -> f32 {
648 self.laid(MEASURING_RUN).size.y
649 }
650
651 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
662const MEASURING_RUN: &str = "Ag";
665
666#[derive(Clone, Copy, PartialEq, Eq)]
668enum Fitting {
669 ByCharacter,
672 BySegment,
675}
676
677struct Kept {
679 first: usize,
681 segments: Vec<String>,
682 elided: &'static str,
684}
685
686struct Laid {
689 caption: &'static str,
690 value: String,
691 emphasis: blockworx_editor::title_block::Emphasis,
692 kept: Option<Kept>,
693}
694
695impl Laid {
696 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
734struct 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
790fn 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
817const ELIDED: &str = "\u{2026}/";
819
820fn 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
837const KAPPA: f32 = 0.552_284_8;
840
841fn 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
892fn paint(theme: &Theme, role: Role) -> krilla::paint::Paint {
895 let color = theme.resolve(role);
896 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
909fn 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;