1use 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
42const SCALE: f32 = 0.75;
49const MARGIN: f32 = 36.0;
51const MIN_PAGE: Vec2 = Vec2::new(288.0, 216.0);
54const MAX_PAGE: f32 = 14_400.0;
58const BLOCK_ROWS: f32 = 4.0;
61const LEADING: f32 = 4.0;
63const BLOCK_PAD: f32 = 3.0;
65const COLUMN_GAP: f32 = 12.0;
68const CAPTION_GAP: f32 = 4.0;
69
70#[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 fn line(self) -> f32 {
87 self.0 + LEADING
88 }
89
90 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}
99const BLOCK_RADIUS: f32 = 3.0;
101const BLOCK_BORDER: f32 = 0.6;
102
103pub struct Scene<'a> {
105 pub document: IndexedDocument<'a>,
106 pub theme: &'a crate::theme::Theme,
109 pub scheme: blockworx_paint::Scheme,
111 pub font: blockworx_paint::FontChoice,
112 pub block: crate::tools::title_block::TitleBlock,
115 pub provenance: Option<Provenance>,
118}
119
120#[derive(Clone, Copy, PartialEq, Debug)]
124pub struct Fit {
125 scale: f32,
126 offset: Vec2,
128}
129
130impl Fit {
131 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 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
151pub struct Sheet {
155 pub size: Vec2,
156 pub fit: Fit,
157 footer: f32,
158}
159
160impl Sheet {
161 pub fn for_frame(frame: Rect, text: TextSize) -> Sheet {
164 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 #[cfg_attr(not(test), allow(dead_code))]
190 fn content_box(&self) -> Rect {
191 content_box(self.size, self.footer)
192 }
193}
194
195fn 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
203struct Scoped {
206 path: BlockPath,
207 title: String,
208 page: usize,
209 children: Vec<Scoped>,
211}
212
213impl Scoped {
214 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
232fn destination(page: usize) -> XyzDestination {
234 XyzDestination::new(page, Point::from_xy(0.0, 0.0))
235}
236
237fn 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
247fn 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#[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
288pub 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 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
342fn 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
359struct Page<'a> {
361 document: &'a IndexedDocument<'a>,
362 theme: &'a crate::theme::Theme,
363 font: blockworx_paint::FontChoice,
364 sheet_font: Option<&'a PdfFont>,
367 text: TextSize,
368 scope: &'a Scoped,
369 block: &'a crate::tools::title_block::TitleBlock,
371 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 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
452struct Printed {
455 #[cfg_attr(not(test), allow(dead_code))]
456 frame: Rect,
457 links: Vec<(Rect, usize, String)>,
458}
459
460fn 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 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 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#[derive(Clone, Copy)]
586struct At {
587 x: f32,
588 baseline: f32,
589 captions: f32,
590}
591
592#[derive(Clone, Copy)]
594struct Placed {
595 x: f32,
596 top: f32,
597 height: f32,
598}
599
600fn 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 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
632fn 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
641struct 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 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 fn height(&mut self) -> f32 {
665 self.galley(MEASURING_RUN).size().y
666 }
667
668 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
680const MEASURING_RUN: &str = "Ag";
683
684#[derive(Clone, Copy, PartialEq, Eq)]
686enum Fitting {
687 ByCharacter,
690 BySegment,
693}
694
695struct Kept {
697 first: usize,
699 segments: Vec<String>,
700 elided: &'static str,
702}
703
704struct Laid {
707 caption: &'static str,
708 value: String,
709 emphasis: crate::tools::title_block::Emphasis,
710 kept: Option<Kept>,
711}
712
713impl Laid {
714 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
752struct 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
808fn 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
835const ELIDED: &str = "\u{2026}/";
837
838fn 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
855const KAPPA: f32 = 0.552_284_8;
858
859fn 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
923fn 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;