1use krilla::action::LinkAction;
18use krilla::annotation::{Annotation, LinkAnnotation, Target};
19use krilla::color::rgb;
20use krilla::destination::XyzDestination;
21use krilla::geom::{PathBuilder, Point, Rect, Size, Transform};
22use krilla::image::Image;
23use krilla::metadata::{DateTime, Metadata as PdfMetadata};
24use krilla::num::NormalizedF32;
25use krilla::outline::{Outline, OutlineNode};
26use krilla::page::PageSettings;
27use krilla::paint::{Fill, FillRule};
28use krilla::surface::Surface;
29use krilla::text::{Font, GlyphId, KrillaGlyph, Tag};
30use krilla::{Document, SerializeSettings};
31
32use crate::LayoutOutput;
33use crate::content::Metadata;
34use crate::fonts::FontRegistry;
35use crate::images::Assets;
36use crate::pages::{Corners, DrawItem, Glyph, LinkTo, OutlineEntry, Page, PageBox};
37use crate::style::Color;
38
39#[derive(Debug, thiserror::Error)]
41pub enum PdfError {
42 #[error("font {0} could not be embedded")]
44 Font(String),
45 #[error("image {0} could not be embedded")]
47 Image(String),
48 #[error("page {number} draws {kind} outside what PDF can express")]
50 Geometry {
51 number: u32,
53 kind: &'static str,
55 },
56 #[error("PDF serialization failed: {0}")]
58 Serialize(String),
59}
60
61pub fn write(
71 output: &LayoutOutput,
72 registry: &FontRegistry,
73 assets: &Assets,
74 metadata: &Metadata,
75) -> Result<Vec<u8>, PdfError> {
76 write_with(
77 output,
78 registry,
79 assets,
80 metadata,
81 SerializeSettings::default(),
82 )
83}
84
85fn write_with(
86 output: &LayoutOutput,
87 registry: &FontRegistry,
88 assets: &Assets,
89 metadata: &Metadata,
90 settings: SerializeSettings,
91) -> Result<Vec<u8>, PdfError> {
92 let fonts = embed_fonts(registry)?;
93 let images = embed_images(assets)?;
94 let mut document = Document::new_with(settings);
95 document.set_metadata(document_metadata(metadata));
96 for page in &output.pages {
97 let mut pdf_page = document.start_page_with(PageSettings::new(page.width, page.height));
98 let mut surface = pdf_page.surface();
99 for item in &page.items {
100 paint(&mut surface, item, page, &fonts, &images, registry)?;
101 }
102 surface.finish();
103 for link in &page.links {
104 for area in &link.areas {
105 if let Some(annotation) = annotation(area, &link.to) {
106 pdf_page.add_annotation(annotation);
107 }
108 }
109 }
110 pdf_page.finish();
111 }
112 if !output.navigation.outline.is_empty() {
113 let mut outline = Outline::new();
114 for entry in &output.navigation.outline {
115 outline.push_child(outline_node(entry));
116 }
117 document.set_outline(outline);
118 }
119 document
120 .finish()
121 .map_err(|e| PdfError::Serialize(format!("{e:?}")))
122}
123
124fn annotation(area: &PageBox, to: &LinkTo) -> Option<Annotation> {
127 let rect = Rect::from_xywh(area.x, area.y, area.width, area.height)?;
128 let target = match to {
129 LinkTo::Place { place, .. } => Target::Destination(destination(place).into()),
130 LinkTo::Uri(uri) => Target::Action(LinkAction::new(uri.clone()).into()),
131 };
132 Some(LinkAnnotation::new(rect, target).into())
133}
134
135fn destination(place: &PageBox) -> XyzDestination {
137 XyzDestination::new(place.page as usize, Point::from_xy(place.x, place.y))
138}
139
140fn outline_node(entry: &OutlineEntry) -> OutlineNode {
142 let mut node = OutlineNode::new(entry.title.clone(), destination(&entry.place));
143 for child in &entry.children {
144 node.push_child(outline_node(child));
145 }
146 node
147}
148
149fn embed_images(assets: &Assets) -> Result<Vec<Image>, PdfError> {
158 assets
159 .assets()
160 .iter()
161 .enumerate()
162 .map(|(index, asset)| {
163 let bytes = assets
164 .bytes(index as u32)
165 .ok_or_else(|| PdfError::Image(asset.url.clone()))?
166 .to_vec();
167 embed_image(bytes).ok_or_else(|| PdfError::Image(asset.url.clone()))
168 })
169 .collect()
170}
171
172fn embed_image(bytes: Vec<u8>) -> Option<Image> {
177 let data = krilla::Data::from(bytes);
178 match () {
179 _ if data.as_ref().starts_with(b"\x89PNG\r\n\x1a\n") => Image::from_png(data, false),
180 _ if data.as_ref().starts_with(&[0xFF, 0xD8]) => Image::from_jpeg(data, false),
181 _ if data.as_ref().starts_with(b"GIF8") => Image::from_gif(data, false),
182 _ if data.as_ref().starts_with(b"RIFF") => Image::from_webp(data, false),
183 _ => None,
184 }
185}
186
187fn embed_fonts(registry: &FontRegistry) -> Result<Vec<Font>, PdfError> {
195 (0..registry.len() as u16)
196 .map(|id| {
197 let name = || {
198 registry
199 .font_ref(id)
200 .map(|f| f.name.clone())
201 .unwrap_or_else(|| id.to_string())
202 };
203 let bytes = registry.bytes(id).ok_or_else(|| PdfError::Font(name()))?;
204 let variations: Vec<(Tag, f32)> = registry
205 .variations(id)
206 .unwrap_or_default()
207 .iter()
208 .map(|axis| (Tag::new(&axis.tag), axis.value))
209 .collect();
210 Font::new_variable(bytes.into(), 0, &variations).ok_or_else(|| PdfError::Font(name()))
211 })
212 .collect()
213}
214
215fn document_metadata(metadata: &Metadata) -> PdfMetadata {
223 let mut pdf = PdfMetadata::new()
224 .creator("fleuron".to_string())
225 .producer("fleuron".to_string());
226 if let Some(title) = &metadata.title {
227 pdf = pdf.title(title.clone());
228 }
229 if let Some(author) = &metadata.author {
230 pdf = pdf.authors(vec![author.clone()]);
231 }
232 if let Some(language) = metadata.language() {
233 pdf = pdf.language(language.to_string());
234 }
235 if let Some(date) = metadata.extra.get("date").and_then(|date| written(date)) {
236 pdf = pdf.creation_date(date);
237 }
238 pdf
239}
240
241fn written(date: &str) -> Option<DateTime> {
245 let number = |field: Option<&str>, digits: usize| -> Option<u16> {
246 let field = field?;
247 (field.len() == digits && field.bytes().all(|b| b.is_ascii_digit()))
248 .then(|| field.parse().ok())?
249 };
250 let (day, clock) = match date.split_once(['T', ' ']) {
251 Some((day, clock)) => (day, Some(clock)),
252 None => (date, None),
253 };
254 let mut fields = day.split('-');
255 let mut stamp = DateTime::new(number(fields.next(), 4)?);
256 if let Some(month) = number(fields.next(), 2) {
257 stamp = stamp.month(month as u8);
258 if let Some(day) = number(fields.next(), 2) {
259 stamp = stamp.day(day as u8);
260 }
261 }
262 let mut fields = clock.unwrap_or_default().split(':');
263 if let Some(hour) = number(fields.next(), 2) {
264 stamp = stamp.hour(hour as u8);
265 if let Some(minute) = number(fields.next(), 2) {
266 stamp = stamp.minute(minute as u8);
267 if let Some(second) = number(fields.next(), 2) {
268 stamp = stamp.second(second as u8);
269 }
270 }
271 }
272 Some(stamp)
273}
274
275fn paint(
276 surface: &mut Surface,
277 item: &DrawItem,
278 page: &Page,
279 fonts: &[Font],
280 images: &[Image],
281 registry: &FontRegistry,
282) -> Result<(), PdfError> {
283 match item {
284 DrawItem::Text {
285 x,
286 y,
287 font_id,
288 size,
289 text,
290 source,
291 source_map,
292 features: _,
297 origin: _,
298 pseudo_element: _,
299 width: _,
301 layer: _,
304 color,
305 glyphs,
306 } => {
307 ink(surface, *color);
308 let font = fonts
309 .get(*font_id as usize)
310 .ok_or_else(|| PdfError::Font(font_id.to_string()))?;
311 let extracted = extracted(text, source, source_map, glyphs);
315 let placed = place(glyphs, &extracted, *x, *size, *font_id, registry);
316 surface.draw_glyphs(
317 Point::from_xy(*x, *y),
318 &placed,
319 font.clone(),
320 extracted.text,
321 *size,
322 false,
323 );
324 }
325 DrawItem::Rect {
326 x,
327 y,
328 w,
329 h,
330 color,
331 layer: _,
332 } => {
333 ink(surface, *color);
334 let rect = Rect::from_xywh(*x, *y, *w, *h).ok_or(PdfError::Geometry {
335 number: page.number,
336 kind: "a rectangle",
337 })?;
338 let mut builder = PathBuilder::new();
339 builder.push_rect(rect);
340 let path = builder.finish().ok_or(PdfError::Geometry {
341 number: page.number,
342 kind: "a rectangle",
343 })?;
344 surface.draw_path(&path);
345 }
346 DrawItem::Image {
351 x,
352 y,
353 w,
354 h,
355 asset,
356 alpha,
357 layer: _,
358 } => {
359 let image = images
360 .get(*asset as usize)
361 .ok_or_else(|| PdfError::Image(asset.to_string()))?;
362 let size = Size::from_wh(*w, *h).ok_or(PdfError::Geometry {
363 number: page.number,
364 kind: "an image",
365 })?;
366 faded(surface, *alpha, |surface| {
367 surface.push_transform(&Transform::from_translate(*x, *y));
368 surface.draw_image(image.clone(), size);
369 surface.pop();
370 });
371 }
372 DrawItem::Background {
376 x,
377 y,
378 w,
379 h,
380 radii,
381 tile_x,
382 tile_y,
383 tile_w,
384 tile_h,
385 repeat,
386 asset,
387 alpha,
388 layer: _,
389 } => {
390 let image = images
391 .get(*asset as usize)
392 .ok_or_else(|| PdfError::Image(asset.to_string()))?;
393 let size = Size::from_wh(*tile_w, *tile_h).ok_or(PdfError::Geometry {
394 number: page.number,
395 kind: "a background image",
396 })?;
397 let clip = Rect::from_xywh(*x, *y, *w, *h).ok_or(PdfError::Geometry {
398 number: page.number,
399 kind: "a background box",
400 })?;
401 let mut builder = PathBuilder::new();
402 if radii.is_square() {
403 builder.push_rect(clip);
404 } else {
405 outline(&mut builder, [*x, *y, *w, *h], *radii);
406 }
407 let path = builder.finish().ok_or(PdfError::Geometry {
408 number: page.number,
409 kind: "a background box",
410 })?;
411 faded(surface, *alpha, |surface| {
412 surface.push_clip_path(&path, &FillRule::NonZero);
413 for (left, top) in tiles(
414 [*x, *y, *w, *h],
415 [*tile_x, *tile_y, *tile_w, *tile_h],
416 *repeat,
417 ) {
418 surface.push_transform(&Transform::from_translate(left, top));
419 surface.draw_image(image.clone(), size);
420 surface.pop();
421 }
422 surface.pop();
423 });
424 }
425 DrawItem::Rounded {
427 x,
428 y,
429 w,
430 h,
431 radii,
432 ring,
433 color,
434 layer: _,
435 } => {
436 surface.set_fill(Some(Fill {
437 paint: rgb::Color::new(color.r, color.g, color.b).into(),
438 opacity: opacity(color.a),
439 rule: FillRule::EvenOdd,
440 }));
441 let mut builder = PathBuilder::new();
442 outline(&mut builder, [*x, *y, *w, *h], *radii);
443 let inner = [
444 x + ring.left,
445 y + ring.top,
446 w - ring.left - ring.right,
447 h - ring.top - ring.bottom,
448 ];
449 let hollow = ring.top > 0.0 || ring.right > 0.0 || ring.bottom > 0.0 || ring.left > 0.0;
450 if hollow && inner[2] > 0.0 && inner[3] > 0.0 {
451 outline(&mut builder, inner, radii.inside(*ring));
452 }
453 let path = builder.finish().ok_or(PdfError::Geometry {
454 number: page.number,
455 kind: "a rounded box",
456 })?;
457 surface.draw_path(&path);
458 }
459 }
460 Ok(())
461}
462
463const KAPPA: f32 = 0.552_284_8;
467
468fn outline(builder: &mut PathBuilder, box_: [f32; 4], radii: Corners) {
471 let [x, y, w, h] = box_;
472 let Corners {
473 top_left: tl,
474 top_right: tr,
475 bottom_right: br,
476 bottom_left: bl,
477 } = radii;
478 let near = 1.0 - KAPPA;
479 builder.move_to(x + tl.x, y);
480 builder.line_to(x + w - tr.x, y);
481 builder.cubic_to(
482 x + w - tr.x * near,
483 y,
484 x + w,
485 y + tr.y * near,
486 x + w,
487 y + tr.y,
488 );
489 builder.line_to(x + w, y + h - br.y);
490 builder.cubic_to(
491 x + w,
492 y + h - br.y * near,
493 x + w - br.x * near,
494 y + h,
495 x + w - br.x,
496 y + h,
497 );
498 builder.line_to(x + bl.x, y + h);
499 builder.cubic_to(
500 x + bl.x * near,
501 y + h,
502 x,
503 y + h - bl.y * near,
504 x,
505 y + h - bl.y,
506 );
507 builder.line_to(x, y + tl.y);
508 builder.cubic_to(x, y + tl.y * near, x + tl.x * near, y, x + tl.x, y);
509 builder.close();
510}
511
512fn faded(surface: &mut Surface, alpha: u8, draw: impl FnOnce(&mut Surface)) {
515 if alpha == 255 {
516 draw(surface);
517 return;
518 }
519 surface.push_opacity(opacity(alpha));
520 draw(surface);
521 surface.pop();
522}
523
524const TILES: usize = 4096;
529
530fn tiles(box_: [f32; 4], tile: [f32; 4], repeat: bool) -> Vec<(f32, f32)> {
534 let [x, y, w, h] = box_;
535 let [tile_x, tile_y, tile_w, tile_h] = tile;
536 if !repeat {
537 return vec![(tile_x, tile_y)];
538 }
539 let steps = |start: f32, edge: f32, extent: f32, step: f32| {
542 let before = ((start - edge) / step).ceil().max(0.0) as usize;
543 let after = ((edge + extent - start) / step).ceil().max(1.0) as usize;
544 (start - before as f32 * step, (before + after).min(TILES))
545 };
546 let (first_x, across) = steps(tile_x, x, w, tile_w);
547 let (first_y, down) = steps(tile_y, y, h, tile_h);
548 let mut placed = Vec::with_capacity((across * down).min(TILES));
549 for row in 0..down {
550 for column in 0..across {
551 if placed.len() == TILES {
552 return placed;
553 }
554 placed.push((
555 first_x + column as f32 * tile_w,
556 first_y + row as f32 * tile_h,
557 ));
558 }
559 }
560 placed
561}
562
563fn ink(surface: &mut Surface, color: Color) {
566 surface.set_fill((color != Color::BLACK).then(|| Fill {
567 paint: rgb::Color::new(color.r, color.g, color.b).into(),
568 opacity: opacity(color.a),
569 ..Fill::default()
570 }));
571}
572
573fn opacity(alpha: u8) -> NormalizedF32 {
575 NormalizedF32::new(alpha as f32 / 255.0).unwrap_or(NormalizedF32::ONE)
576}
577
578fn place(
586 glyphs: &[Glyph],
587 extracted: &Extracted<'_>,
588 origin: f32,
589 size: f32,
590 font_id: u16,
591 registry: &FontRegistry,
592) -> Vec<KrillaGlyph> {
593 let upem = registry
594 .metrics(font_id)
595 .map(|m| m.units_per_em as f32)
596 .unwrap_or(1000.0);
597 let offset = glyphs.first().map(|g| g.x - origin).unwrap_or(0.0);
601 glyphs
602 .iter()
603 .enumerate()
604 .map(|(i, glyph)| {
605 let advance = match glyphs.get(i + 1) {
606 Some(next) => (next.x - glyph.x) / size,
607 None => registry.advance_width(font_id, glyph.id).unwrap_or(0) as f32 / upem,
608 };
609 KrillaGlyph {
610 glyph_id: GlyphId::new(glyph.id),
611 text_range: extracted.ranges[i].clone(),
612 x_advance: advance,
613 x_offset: offset / size,
614 y_offset: 0.0,
615 y_advance: 0.0,
616 location: None,
617 }
618 })
619 .collect()
620}
621
622struct Extracted<'a> {
625 text: &'a str,
626 ranges: Vec<std::ops::Range<usize>>,
627}
628
629fn extracted<'a>(
634 text: &'a str,
635 source: &'a str,
636 source_map: &[u32],
637 glyphs: &[Glyph],
638) -> Extracted<'a> {
639 let transformed = !source_map.is_empty();
640 let read = if transformed { source } else { text };
641 let through = |at: u32| match source_map.get(at as usize) {
642 Some(offset) => *offset,
643 None => read.len() as u32,
644 };
645 let ranges = glyphs
646 .iter()
647 .map(|glyph| {
648 let range = if transformed {
649 through(glyph.range.start)..through(glyph.range.end)
650 } else {
651 glyph.range.start..glyph.range.end
652 };
653 clamp_range(range, read)
654 })
655 .collect();
656 Extracted { text: read, ranges }
657}
658
659fn clamp_range(range: std::ops::Range<u32>, text: &str) -> std::ops::Range<usize> {
664 let floor = |at: u32| {
665 let mut at = (at as usize).min(text.len());
666 while !text.is_char_boundary(at) {
667 at -= 1;
668 }
669 at
670 };
671 let start = floor(range.start);
672 start..floor(range.end).max(start)
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::content::{Attributes, Block, Book, HeadingLevel, Inline, Section};
679 use crate::fonts::{BUNDLED_FONT, Features, bundled_registry};
680 use crate::pages::{Glyph, Side};
681
682 const MAP: &[u8] = include_bytes!("../../../fixtures/images/plate.jpg");
685
686 const ORNAMENT: &[u8] = include_bytes!("../../../fixtures/images/fleuron.png");
689 const ORNAMENT_WEBP: &[u8] = include_bytes!("../../../fixtures/images/fleuron.webp");
690
691 fn registry() -> &'static FontRegistry {
692 static REGISTRY: std::sync::OnceLock<FontRegistry> = std::sync::OnceLock::new();
693 REGISTRY.get_or_init(|| bundled_registry().expect("bundled font parses"))
694 }
695
696 fn book(text: &str) -> Book {
698 let mut book = Book {
699 metadata: Metadata {
700 title: Some("Gulliver\'s Travels".into()),
701 author: Some("Jonathan Swift".into()),
702 extra: [("date".to_string(), "1726-10-28".to_string())]
703 .into_iter()
704 .collect(),
705 },
706 sections: vec![Section {
707 attributes: Default::default(),
708 blocks: vec![Block::Paragraph {
709 id: Default::default(),
710 inlines: vec![Inline::Text {
711 id: Default::default(),
712 value: text.into(),
713 attributes: Attributes::default(),
714 position: None,
715 span: None,
716 }],
717 attributes: Attributes::default(),
718 position: None,
719 span: None,
720 }],
721 ..Default::default()
722 }],
723 };
724 book.assign_node_ids();
725 book
726 }
727
728 fn chapter() -> Book {
730 let mut book = Book {
731 metadata: Metadata::default(),
732 sections: vec![Section {
733 attributes: Default::default(),
734 blocks: vec![
735 Block::Heading {
736 id: Default::default(),
737 level: HeadingLevel::H1,
738 inlines: vec![Inline::Text {
739 id: Default::default(),
740 value: "Chapter One".into(),
741 attributes: Attributes::default(),
742 position: None,
743 span: None,
744 }],
745 attributes: Attributes::default(),
746 position: None,
747 span: None,
748 },
749 Block::Paragraph {
750 id: Default::default(),
751 inlines: vec![Inline::Text {
752 id: Default::default(),
753 value: "The wind came off the water.".into(),
754 attributes: Attributes::default(),
755 position: None,
756 span: None,
757 }],
758 attributes: Attributes::default(),
759 position: None,
760 span: None,
761 },
762 ],
763 ..Default::default()
764 }],
765 };
766 book.assign_node_ids();
767 book
768 }
769
770 fn page_of(items: Vec<DrawItem>, width: f32, height: f32) -> LayoutOutput {
773 LayoutOutput {
774 pages: vec![Page {
775 number: 1,
776 side: Side::Recto,
777 width,
778 height,
779 sections: Vec::new(),
780 items,
781 links: Vec::new(),
782 }],
783 fonts: registry().font_ref(0).cloned().into_iter().collect(),
784 assets: Vec::new(),
785 warnings: Vec::new(),
786 navigation: Default::default(),
787 }
788 }
789
790 fn assets(files: &[(&str, &[u8])]) -> Assets {
792 let mut assets = Assets::none();
793 for (url, bytes) in files {
794 assets.add(url, bytes.to_vec());
795 }
796 assets
797 }
798
799 fn readable(output: &LayoutOutput, metadata: &Metadata) -> String {
802 with_images(output, &Assets::none(), metadata)
803 }
804
805 fn with_images(output: &LayoutOutput, assets: &Assets, metadata: &Metadata) -> String {
807 latin1(&bytes_of(output, assets, metadata))
808 }
809
810 fn bytes_of(output: &LayoutOutput, assets: &Assets, metadata: &Metadata) -> Vec<u8> {
811 write_with(
812 output,
813 registry(),
814 assets,
815 metadata,
816 SerializeSettings {
817 compress_content_streams: false,
818 ..Default::default()
819 },
820 )
821 .expect("the bundled face embeds")
822 }
823
824 fn latin1(bytes: &[u8]) -> String {
825 bytes.iter().map(|b| *b as char).collect()
826 }
827
828 fn laid_out(book: &Book) -> LayoutOutput {
829 let styles = crate::style::defaults(book, registry());
830 crate::layout::layout_book(book, &styles, registry(), &Assets::none())
831 }
832
833 fn content(pdf: &str) -> String {
837 let streams: Vec<&str> = pdf
841 .split("\nstream\n")
842 .skip(1)
843 .filter_map(|rest| rest.split_once("\nendstream"))
844 .map(|(body, _)| body)
845 .filter(|body| body.is_ascii() && body.contains(" cm\n"))
846 .collect();
847 assert!(!streams.is_empty(), "no page content stream in:\n{pdf}");
848 streams.join("\n")
849 }
850
851 fn showing_offset(pdf: &str) -> Option<f32> {
854 let show = &pdf[pdf.find("[(")?..pdf.find("] TJ")?];
855 show.rsplit_once(") ")?.1.split_once(" (")?.0.parse().ok()
856 }
857
858 #[test]
861 fn page_trim_becomes_the_media_box() {
862 let pdf = readable(&page_of(Vec::new(), 432.0, 648.0), &Metadata::default());
863 assert!(
864 pdf.contains("/MediaBox [0 0 432 648]"),
865 "media box missing from:\n{pdf}"
866 );
867 }
868
869 #[test]
874 fn glyphs_show_at_the_positions_layout_gave_them() {
875 let d = registry().char_glyph(0, 'd').unwrap();
876 let advance = registry().advance_width(0, d).unwrap() as f32 / 1000.0 * 10.0;
877 let items = vec![DrawItem::Text {
878 x: 0.0,
879 y: 20.0,
880 font_id: 0,
881 size: 10.0,
882 width: advance * 2.0 - 1.0,
883 text: "dd".into(),
884 source: String::new(),
885 source_map: Vec::new(),
886 origin: None,
887 pseudo_element: None,
888 features: Features::NONE,
889 color: Color::BLACK,
890 layer: 0,
891 glyphs: vec![
892 Glyph {
893 id: d,
894 x: 0.0,
895 range: 0..1,
896 },
897 Glyph {
898 id: d,
899 x: advance - 1.0,
900 range: 1..2,
901 },
902 ],
903 }];
904 let pdf = readable(&page_of(items, 200.0, 200.0), &Metadata::default());
905 let offset = showing_offset(&pdf).expect("no showing offset in:\n{pdf}");
908 assert!(
909 (offset - 100.0).abs() < 0.01,
910 "pair shows {offset}/1000 em tight, layout asked for 100"
911 );
912 }
913
914 #[test]
919 fn a_layered_page_paints_in_the_order_the_list_is_in() {
920 let under = Color::rgb(0, 51, 102);
921 let over = Color::rgb(180, 30, 30);
922 let items = vec![
926 DrawItem::Rect {
927 x: 0.0,
928 y: 0.0,
929 w: 100.0,
930 h: 100.0,
931 color: under,
932 layer: -1,
933 },
934 DrawItem::Rect {
935 x: 20.0,
936 y: 20.0,
937 w: 60.0,
938 h: 60.0,
939 color: over,
940 layer: 10,
941 },
942 ];
943 let painted = content(&readable(
944 &page_of(items, 200.0, 200.0),
945 &Metadata::default(),
946 ));
947 let lower = painted
948 .find("0 0.2 0.4 rg")
949 .expect("the lowered block filled with its own colour");
950 let raised = painted
951 .find("0.7058824 0.11764706 0.11764706 rg")
952 .expect("the raised block filled with its own colour");
953 assert!(
954 lower < raised,
955 "the writer painted the raised block first:\n{painted}"
956 );
957 }
958
959 #[test]
961 fn rect_items_paint_as_filled_paths() {
962 let items = vec![DrawItem::Rect {
963 x: 54.0,
964 y: 100.0,
965 w: 324.0,
966 h: 0.5,
967 color: Color::BLACK,
968 layer: 0,
969 }];
970 let pdf = readable(&page_of(items, 432.0, 648.0), &Metadata::default());
971 assert!(
972 pdf.contains("54 100 m") && pdf.contains("378 100 l") && pdf.contains("\nf\n"),
973 "rect did not paint as a filled path:\n{pdf}"
974 );
975 }
976
977 #[test]
983 fn a_rule_across_a_run_paints_where_layout_put_it() {
984 let book = chapter();
985 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
986 "decoration.css",
987 "h1 { text-decoration: underline }",
988 )])
989 .compile(&book, registry());
990 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
991 let rules: Vec<(f32, f32, f32, f32)> = output.pages[0]
992 .items
993 .iter()
994 .filter_map(|item| match item {
995 DrawItem::Rect { x, y, w, h, .. } => Some((*x, *y, *w, *h)),
996 _ => None,
997 })
998 .collect();
999 assert_eq!(rules.len(), 1, "the heading did not take one rule");
1000 let (x, y, w, h) = rules[0];
1001 let painted = content(&readable(&output, &Metadata::default()));
1002 let corners = [
1003 format!("{x} {y} m"),
1004 format!("{} {y} l", x + w),
1005 format!("{} {} l", x + w, y + h),
1006 ];
1007 for corner in &corners {
1008 assert!(
1009 painted.contains(corner),
1010 "the export did not paint `{corner}`:\n{painted}",
1011 );
1012 }
1013 }
1014
1015 #[test]
1019 fn items_fill_with_the_colour_they_carry() {
1020 let book = chapter();
1021 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1022 "colour.css",
1023 "h1 { color: #b41e1e }",
1024 )])
1025 .compile(&book, registry());
1026 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
1027 let heading = output.pages[0]
1028 .items
1029 .iter()
1030 .find_map(|item| match item {
1031 DrawItem::Text { text, color, .. } if text.starts_with("Chapter") => Some(*color),
1032 _ => None,
1033 })
1034 .expect("the heading opens the first page");
1035 assert_eq!(heading, Color::rgb(180, 30, 30));
1036 let painted = content(&readable(&output, &Metadata::default()));
1037 let red = painted
1039 .find("0.7058824 0.11764706 0.11764706 rg")
1040 .unwrap_or_else(|| {
1041 panic!("the heading did not fill with the colour its run carries:\n{painted}")
1042 });
1043 assert!(
1046 painted[red..].contains("\n0 g\n"),
1047 "the heading's colour ran on into the prose under it:\n{painted}"
1048 );
1049
1050 let rule = page_of(
1051 vec![DrawItem::Rect {
1052 x: 10.0,
1053 y: 40.0,
1054 w: 100.0,
1055 h: 0.5,
1056 color: Color::rgb(0, 51, 102),
1057 layer: 0,
1058 }],
1059 200.0,
1060 200.0,
1061 );
1062 let painted = content(&readable(&rule, &Metadata::default()));
1063 assert!(
1064 painted.contains("0 0.2 0.4 rg"),
1065 "the rule did not fill with the colour it carries:\n{painted}"
1066 );
1067
1068 let black = content(&readable(&laid_out(&book), &Metadata::default()));
1069 assert!(
1070 !black.contains(" rg"),
1071 "a page in black wrote a colour of its own:\n{black}"
1072 );
1073 }
1074
1075 #[test]
1079 fn a_run_in_a_colour_with_alpha_fills_at_that_alpha() {
1080 let book = chapter();
1081 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1082 "alpha.css",
1083 "h1 { color: rgba(180, 30, 30, 0.25) }",
1084 )])
1085 .compile(&book, registry());
1086 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
1087 let pdf = readable(&output, &Metadata::default());
1088 assert!(
1089 pdf.contains("/ca 0.2509804"),
1090 "no fill opacity of a quarter in:\n{pdf}"
1091 );
1092 let painted = content(&pdf);
1093 let red = painted
1094 .find("0.7058824 0.11764706 0.11764706 rg")
1095 .unwrap_or_else(|| panic!("the heading lost its colour:\n{painted}"));
1096 assert!(
1097 painted[..red].contains(" gs\n") || painted[red..].contains(" gs\n"),
1098 "the fill opacity is never set on the page:\n{painted}"
1099 );
1100 }
1101
1102 #[test]
1105 fn the_face_embeds_as_a_subset() {
1106 let pdf = readable(&laid_out(&book("difficult offices")), &Metadata::default());
1107 let tag = pdf
1108 .find("+EBGaramond-Regular")
1109 .expect("no subset-tagged BaseFont");
1110 assert!(
1111 pdf[tag - 6..tag].chars().all(|c| c.is_ascii_uppercase()),
1112 "BaseFont has no six-letter subset tag"
1113 );
1114 assert!(pdf.contains("/FontFile2"), "no embedded font program");
1115 assert!(
1116 pdf.len() < BUNDLED_FONT.len(),
1117 "the whole PDF ({} bytes) is no smaller than the full face ({} bytes)",
1118 pdf.len(),
1119 BUNDLED_FONT.len()
1120 );
1121 }
1122
1123 #[test]
1127 fn a_jpeg_embeds_the_bytes_it_arrived_as() {
1128 let items = vec![DrawItem::Image {
1129 x: 40.0,
1130 y: 60.0,
1131 w: 115.2,
1132 h: 76.8,
1133 asset: 0,
1134 alpha: 255,
1135 layer: 0,
1136 }];
1137 let table = assets(&[("plate.jpg", MAP)]);
1138 let bytes = bytes_of(&page_of(items, 432.0, 648.0), &table, &Metadata::default());
1139 let pdf = latin1(&bytes);
1140 assert!(pdf.contains("/DCTDecode"), "the map was re-encoded");
1141 assert!(
1142 bytes.windows(MAP.len()).any(|window| window == MAP),
1143 "the embedded stream is not the file that went in",
1144 );
1145 }
1146
1147 #[test]
1151 fn a_raster_image_with_alpha_embeds_a_soft_mask() {
1152 for (url, bytes) in [("fleuron.png", ORNAMENT), ("fleuron.webp", ORNAMENT_WEBP)] {
1153 let items = vec![
1154 DrawItem::Rect {
1155 x: 0.0,
1156 y: 0.0,
1157 w: 432.0,
1158 h: 648.0,
1159 color: Color::BLACK,
1160 layer: 0,
1161 },
1162 DrawItem::Image {
1163 x: 200.0,
1164 y: 300.0,
1165 w: 30.72,
1166 h: 30.72,
1167 asset: 0,
1168 alpha: 255,
1169 layer: 0,
1170 },
1171 ];
1172 let table = assets(&[(url, bytes)]);
1173 let pdf = with_images(&page_of(items, 432.0, 648.0), &table, &Metadata::default());
1174 assert!(
1175 pdf.contains("/SMask"),
1176 "{url}: the ornament's transparency was flattened away",
1177 );
1178 assert!(
1179 pdf.contains("/ColorSpace /DeviceGray"),
1180 "{url}: no grayscale mask stream",
1181 );
1182 assert!(
1183 !pdf.contains("/DCTDecode"),
1184 "{url}: a raster image went in as a JPEG stream",
1185 );
1186 }
1187 }
1188
1189 fn round(radius: f32) -> Corners {
1191 let radius = crate::pages::Radius {
1192 x: radius,
1193 y: radius,
1194 };
1195 Corners {
1196 top_left: radius,
1197 top_right: radius,
1198 bottom_right: radius,
1199 bottom_left: radius,
1200 }
1201 }
1202
1203 #[test]
1206 fn a_rounded_box_fills_a_curved_path() {
1207 let items = vec![
1208 DrawItem::Rounded {
1209 x: 20.0,
1210 y: 20.0,
1211 w: 100.0,
1212 h: 40.0,
1213 radii: round(3.0),
1214 ring: crate::style::Edges::all(0.0),
1215 color: Color::rgb(0xf4, 0xf1, 0xea),
1216 layer: 0,
1217 },
1218 DrawItem::Rounded {
1219 x: 20.0,
1220 y: 80.0,
1221 w: 100.0,
1222 h: 40.0,
1223 radii: round(6.0),
1224 ring: crate::style::Edges::all(2.0),
1225 color: Color::rgb(0, 51, 102),
1226 layer: 0,
1227 },
1228 ];
1229 let painted = content(&readable(
1230 &page_of(items, 200.0, 200.0),
1231 &Metadata::default(),
1232 ));
1233 let curves = painted.matches(" c\n").count();
1234 assert_eq!(
1235 curves, 12,
1236 "one curve per corner of three outlines:\n{painted}"
1237 );
1238 assert!(
1239 painted.contains("\nf*\n"),
1240 "the ring is not filled by the even-odd rule:\n{painted}"
1241 );
1242 }
1243
1244 #[test]
1249 fn a_rounded_chip_draws_its_corners() {
1250 let mut book = book("roll for the modifier");
1251 let Some(Block::Paragraph { inlines, .. }) = book.sections[0].blocks.first_mut() else {
1252 panic!("the fixture opens with a paragraph");
1253 };
1254 inlines.push(Inline::Code {
1255 id: Default::default(),
1256 value: "2d6".into(),
1257 attributes: Attributes::default(),
1258 position: None,
1259 span: None,
1260 });
1261 book.assign_node_ids();
1262 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1263 "chip.css",
1264 "code { background-color: #858585; padding: 2pt 4pt; border-radius: 3pt }",
1265 )])
1266 .compile(&book, registry());
1267 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
1268 let chip = output.pages[0]
1269 .items
1270 .iter()
1271 .filter(|item| matches!(item, DrawItem::Rounded { .. }))
1272 .count();
1273 assert_eq!(chip, 1, "the chip is not one rounded box");
1274 let painted = content(&readable(&output, &Metadata::default()));
1275 assert_eq!(
1276 painted.matches(" c\n").count(),
1277 4,
1278 "one curve per corner of the chip:\n{painted}",
1279 );
1280 }
1281
1282 #[test]
1285 fn a_rounded_background_clips_to_a_curved_path() {
1286 let items = vec![DrawItem::Background {
1287 x: 40.0,
1288 y: 60.0,
1289 w: 120.0,
1290 h: 80.0,
1291 radii: round(3.0),
1292 tile_x: 40.0,
1293 tile_y: 60.0,
1294 tile_w: 120.0,
1295 tile_h: 80.0,
1296 repeat: false,
1297 asset: 0,
1298 alpha: 255,
1299 layer: 0,
1300 }];
1301 let table = assets(&[("plate.jpg", MAP)]);
1302 let painted = content(&with_images(
1303 &page_of(items, 432.0, 648.0),
1304 &table,
1305 &Metadata::default(),
1306 ));
1307 let clip = painted
1308 .find("\nW\n")
1309 .unwrap_or_else(|| panic!("the image is not clipped:\n{painted}"));
1310 assert_eq!(
1311 painted[..clip].matches(" c\n").count(),
1312 4,
1313 "the clip does not follow four curved corners:\n{painted}"
1314 );
1315 }
1316
1317 #[test]
1321 fn a_translucent_tint_over_a_page_image_fills_at_its_alpha() {
1322 let items = vec![
1323 DrawItem::Background {
1324 x: 0.0,
1325 y: 0.0,
1326 w: 432.0,
1327 h: 648.0,
1328 radii: Corners::SQUARE,
1329 tile_x: 0.0,
1330 tile_y: 0.0,
1331 tile_w: 432.0,
1332 tile_h: 648.0,
1333 repeat: false,
1334 asset: 0,
1335 alpha: 255,
1336 layer: DrawItem::PAGE_BACKGROUND,
1337 },
1338 DrawItem::Rect {
1339 x: 54.0,
1340 y: 100.0,
1341 w: 324.0,
1342 h: 120.0,
1343 color: Color::rgba(0, 0, 0, 64),
1344 layer: 0,
1345 },
1346 ];
1347 let table = assets(&[("plate.jpg", MAP)]);
1348 let pdf = with_images(&page_of(items, 432.0, 648.0), &table, &Metadata::default());
1349 assert!(
1350 pdf.contains("/ca 0.2509804"),
1351 "the tint fills opaque:\n{pdf}"
1352 );
1353 let painted = content(&pdf);
1354 let image = painted
1355 .find(" Do\n")
1356 .unwrap_or_else(|| panic!("the page image is not drawn:\n{painted}"));
1357 let tint = painted
1358 .find("0 0 0 rg")
1359 .unwrap_or_else(|| panic!("the tint is not filled:\n{painted}"));
1360 assert!(image < tint, "the tint is under the image:\n{painted}");
1361 }
1362
1363 #[test]
1366 fn an_image_with_alpha_draws_at_that_alpha() {
1367 let image = |alpha| {
1368 vec![DrawItem::Image {
1369 x: 54.0,
1370 y: 90.0,
1371 w: 72.0,
1372 h: 48.0,
1373 asset: 0,
1374 alpha,
1375 layer: 0,
1376 }]
1377 };
1378 let table = assets(&[("plate.jpg", MAP)]);
1379 let faded = with_images(
1380 &page_of(image(128), 432.0, 648.0),
1381 &table,
1382 &Metadata::default(),
1383 );
1384 assert!(
1385 faded.contains("/ca 0.5019608") && faded.contains("/CA 0.5019608"),
1386 "no opacity of a half in:\n{faded}"
1387 );
1388 let opaque = with_images(
1389 &page_of(image(255), 432.0, 648.0),
1390 &table,
1391 &Metadata::default(),
1392 );
1393 assert!(!opaque.contains("/ca "), "an opaque image set an opacity");
1394 }
1395
1396 #[test]
1400 fn an_image_draws_at_the_size_layout_gave_it() {
1401 let items = vec![DrawItem::Image {
1402 x: 54.0,
1403 y: 90.0,
1404 w: 72.0,
1405 h: 48.0,
1406 asset: 0,
1407 alpha: 255,
1408 layer: 0,
1409 }];
1410 let table = assets(&[("plate.jpg", MAP)]);
1411 let (width, height) = table
1412 .lookup("plate.jpg")
1413 .expect("the map is an asset")
1414 .1
1415 .size();
1416 assert!(
1417 (width - 180.0).abs() < 0.01 && (height - 306.72).abs() < 0.01,
1418 "the header sizes the map at {width}x{height}pt",
1419 );
1420 let pdf = with_images(&page_of(items, 432.0, 648.0), &table, &Metadata::default());
1421 assert!(
1425 pdf.contains("72 0 0 48 54 510 cm"),
1426 "the image is not placed at 72x48pt at (54, 90):\n{pdf}",
1427 );
1428 }
1429
1430 #[test]
1438 fn a_wrapped_page_exports_where_the_display_structure_put_it() {
1439 let mut book = book(
1440 "My father had a small estate in Nottinghamshire, and I was the third of \
1441 five sons. He sent me to Emanuel College in Cambridge at fourteen years \
1442 old, where I resided three years and applied myself close to my studies.",
1443 );
1444 book.sections[0].blocks.insert(
1445 0,
1446 Block::Image {
1447 id: Default::default(),
1448 url: "plate.jpg".into(),
1449 alt: "a map of Lilliput".into(),
1450 attributes: Attributes::default(),
1451 position: None,
1452 span: None,
1453 },
1454 );
1455 book.assign_node_ids();
1456 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1457 "anchored.css",
1458 "img { position: absolute; top: 0; left: 0; margin-right: 12pt; wrap-flow: end }",
1459 )])
1460 .compile(&book, registry());
1461 let table = assets(&[("plate.jpg", MAP)]);
1462 let output = crate::layout::layout_book(&book, &styles, registry(), &table);
1463 let page = &output.pages[0];
1464
1465 let image = page
1466 .items
1467 .iter()
1468 .find_map(|item| match item {
1469 DrawItem::Image { x, y, w, h, .. } => Some((*x, *y, *w, *h)),
1470 _ => None,
1471 })
1472 .expect("the image is on the page");
1473 let (x, baseline) = page
1474 .items
1475 .iter()
1476 .find_map(|item| match item {
1477 DrawItem::Text { x, y, .. } if *x > image.0 + image.2 => Some((*x, *y)),
1478 _ => None,
1479 })
1480 .expect("a line is set beside the image");
1481 assert!(
1482 baseline > image.1 && baseline < image.1 + image.3,
1483 "the line at {baseline} is not beside the image at {image:?}",
1484 );
1485
1486 let pdf = content(&with_images(&output, &table, &Metadata::default()));
1487 let box_ = format!(
1491 "{} 0 0 {} {} {} cm",
1492 image.2,
1493 image.3,
1494 image.0,
1495 page.height - image.1 - image.3,
1496 );
1497 assert!(
1498 pdf.contains(&box_),
1499 "the image is not placed at {box_}:\n{pdf}",
1500 );
1501 let text = format!("1 0 0 -1 {x} {baseline} Tm");
1504 assert!(
1505 pdf.contains(&text),
1506 "the line beside the image is not set at {text}:\n{pdf}",
1507 );
1508 }
1509
1510 #[test]
1517 fn a_page_of_positioned_blocks_exports_where_the_display_structure_put_them() {
1518 let text = |value: &str| Inline::Text {
1519 id: Default::default(),
1520 value: value.into(),
1521 attributes: Attributes::default(),
1522 position: None,
1523 span: None,
1524 };
1525 let mut book = book(
1526 "My father had a small estate in Nottinghamshire, and I was the third of \
1527 five sons. He sent me to Emanuel College in Cambridge at fourteen years old.",
1528 );
1529 let blocks = &mut book.sections[0].blocks;
1530 blocks.insert(
1531 0,
1532 Block::Heading {
1533 id: Default::default(),
1534 level: HeadingLevel::H1,
1535 inlines: vec![text("Raised")],
1536 attributes: Attributes::default(),
1537 position: None,
1538 span: None,
1539 },
1540 );
1541 blocks.push(Block::Blockquote {
1542 id: Default::default(),
1543 blocks: vec![Block::Paragraph {
1544 id: Default::default(),
1545 inlines: vec![text("Lifted against the page")],
1546 attributes: Attributes::default(),
1547 position: None,
1548 span: None,
1549 }],
1550 attributes: Attributes::default(),
1551 position: None,
1552 span: None,
1553 });
1554 book.assign_node_ids();
1555 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1556 "positioned.css",
1557 "h1 { position: relative; top: -12pt } \
1558 blockquote { position: absolute; bottom: 1in; left: 0.5in; margin: 0 }",
1559 )])
1560 .compile(&book, registry());
1561 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
1562 let page = &output.pages[0];
1563
1564 let pdf = content(&readable(&output, &Metadata::default()));
1565 for word in ["Raised", "Lifted"] {
1566 let (x, baseline) = page
1567 .items
1568 .iter()
1569 .find_map(|item| match item {
1570 DrawItem::Text { x, y, text, .. } if text.contains(word) => Some((*x, *y)),
1571 _ => None,
1572 })
1573 .unwrap_or_else(|| panic!("no run of {word} on the page"));
1574 let placed = format!("1 0 0 -1 {x} {baseline} Tm");
1577 assert!(
1578 pdf.contains(&placed),
1579 "{word} is not set at {placed}:\n{pdf}",
1580 );
1581 }
1582 }
1583
1584 #[test]
1587 fn an_image_no_host_supplied_is_reported_and_skipped() {
1588 let mut book = Book {
1589 sections: vec![Section {
1590 attributes: Default::default(),
1591 blocks: vec![Block::Image {
1592 id: Default::default(),
1593 url: "missing.png".into(),
1594 alt: "a map".into(),
1595 attributes: Attributes::default(),
1596 position: None,
1597 span: None,
1598 }],
1599 ..Default::default()
1600 }],
1601 ..Default::default()
1602 };
1603 book.assign_node_ids();
1604 let output = laid_out(&book);
1605 assert!(
1606 !output.pages.iter().any(|page| page
1607 .items
1608 .iter()
1609 .any(|item| matches!(item, DrawItem::Image { .. }))),
1610 "an image with no bytes was placed anyway",
1611 );
1612 assert_eq!(output.warnings.len(), 1, "{:?}", output.warnings);
1613 assert!(output.warnings[0].message.contains("missing.png"));
1614 }
1615
1616 #[test]
1618 fn metadata_carries_the_book_title() {
1619 let book = book("Some prose.");
1620 let pdf = readable(&laid_out(&book), &book.metadata);
1621 assert!(pdf.contains("/Title (Gulliver's Travels)"), "no title");
1622 assert!(pdf.contains("/Author (Jonathan Swift)"), "no author");
1623 assert!(pdf.contains("/Producer (fleuron)"), "no producer");
1624 assert!(
1625 pdf.contains("/CreationDate (D:17261028"),
1626 "the book's own date is not the creation date:\n{pdf}",
1627 );
1628 }
1629
1630 #[test]
1634 fn a_book_with_no_date_is_stamped_with_none() {
1635 let mut book = book("Some prose.");
1636 book.metadata.extra.remove("date");
1637 let pdf = readable(&laid_out(&book), &book.metadata);
1638 assert!(!pdf.contains("/CreationDate"), "a clock was read");
1639 }
1640
1641 #[test]
1645 fn glyphs_map_back_to_their_characters() {
1646 let pdf = readable(&laid_out(&book("difficult offices")), &Metadata::default());
1647 assert!(pdf.contains("/ToUnicode"), "no ToUnicode map");
1648 assert!(
1649 pdf.contains("<006600660069>"),
1650 "the ffi ligature does not map back to f, f, i:\n{pdf}"
1651 );
1652 }
1653
1654 #[test]
1658 fn a_transformed_run_extracts_as_it_was_written() {
1659 let book = book("Some prose.");
1660 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1661 "author.css",
1662 "p { text-transform: uppercase }",
1663 )])
1664 .compile(&book, registry());
1665 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
1666 let (text, source) = output
1667 .pages
1668 .iter()
1669 .flat_map(|page| &page.items)
1670 .find_map(|item| match item {
1671 DrawItem::Text { text, source, .. } => Some((text.clone(), source.clone())),
1672 _ => None,
1673 })
1674 .expect("the book set a line");
1675 assert_eq!(
1676 (text.as_str(), source.as_str()),
1677 ("SOME PROSE.", "Some prose.")
1678 );
1679
1680 let pdf = readable(&output, &Metadata::default());
1681 assert!(
1684 pdf.contains("<006F>"),
1685 "the ToUnicode map does not send the capitals to the source:\n{pdf}",
1686 );
1687 }
1688
1689 #[test]
1693 fn a_transformed_first_line_extracts_as_it_was_written() {
1694 let book = book(&"my father had a small estate in nottinghamshire ".repeat(6));
1695 let styles = crate::style::Stylesheets::parse(&[crate::style::Source::author(
1696 "author.css",
1697 "p::first-line { text-transform: uppercase }",
1698 )])
1699 .compile(&book, registry());
1700 let output = crate::layout::layout_book(&book, &styles, registry(), &Assets::none());
1701 let drawn: Vec<(String, String)> = output
1702 .pages
1703 .iter()
1704 .flat_map(|page| &page.items)
1705 .filter_map(|item| match item {
1706 DrawItem::Text { text, source, .. } => Some((text.clone(), source.clone())),
1707 _ => None,
1708 })
1709 .collect();
1710 let (opening, written) = drawn.first().expect("the book set a line");
1711 assert_eq!(opening, &opening.to_uppercase());
1712 assert_eq!(written, &written.to_lowercase());
1713 assert_eq!(opening, &written.to_uppercase());
1714 assert!(
1715 drawn[1..]
1716 .iter()
1717 .all(|(text, source)| text == &text.to_lowercase() && source.is_empty()),
1718 "the transform ran past the first line",
1719 );
1720
1721 let pdf = readable(&output, &Metadata::default());
1722 assert!(
1723 pdf.contains("<006D>"),
1724 "the ToUnicode map does not send the capitals to the source:\n{pdf}",
1725 );
1726 }
1727
1728 #[test]
1731 fn the_document_is_structurally_whole() {
1732 let book = book("Some prose.");
1733 let output = laid_out(&book);
1734 let pdf = readable(&output, &book.metadata);
1735 assert!(pdf.starts_with("%PDF-1.7"), "no PDF header");
1736 assert!(
1737 pdf.contains(&format!("/Type /Pages\n /Count {}", output.pages.len())),
1738 "page tree does not count the display structure's pages"
1739 );
1740 assert!(pdf.contains("startxref"), "no cross-reference offset");
1741 assert!(pdf.trim_end().ends_with("%%EOF"), "no trailer");
1742 }
1743
1744 #[test]
1747 fn writing_is_deterministic() {
1748 let book = book("Some prose, twice over.");
1749 let output = laid_out(&book);
1750 let table = assets(&[("plate.jpg", MAP)]);
1751 let first = write(&output, registry(), &table, &book.metadata).unwrap();
1752 let second = write(&output, registry(), &table, &book.metadata).unwrap();
1753 assert_eq!(first, second);
1754 }
1755}