Skip to main content

fleuron/
pdf.rs

1//! PDF export: display structure in, PDF bytes out.
2//!
3//! The painter for print. Everything it needs already exists on the
4//! page — glyph ids, absolute positions, page trim — so this stage
5//! places what layout decided and decides nothing itself.
6//!
7//! Glyphs are shown at their own x, not at the font's: kerning and
8//! justification put them where they are, and a PDF that re-derived
9//! positions from advances would disagree with the preview. The text
10//! each run was shaped from travels with it, so the writer can build
11//! the glyph-to-character map that makes the text selectable.
12//!
13//! Links and the outline are layout's too: each link arrives on its
14//! page with the areas it covers and the place it goes, and each outline entry with
15//! the place of its heading.
16
17use 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/// What can go wrong turning the display structure into a PDF.
40#[derive(Debug, thiserror::Error)]
41pub enum PdfError {
42    /// The face named could not be embedded.
43    #[error("font {0} could not be embedded")]
44    Font(String),
45    /// The image named is in no format the writer can embed.
46    #[error("image {0} could not be embedded")]
47    Image(String),
48    /// A draw item falls outside what PDF can express.
49    #[error("page {number} draws {kind} outside what PDF can express")]
50    Geometry {
51        /// The folio the offending item is on.
52        number: u32,
53        /// What kind of item it was.
54        kind: &'static str,
55    },
56    /// The writer refused the document.
57    #[error("PDF serialization failed: {0}")]
58    Serialize(String),
59}
60
61/// Writes one laid-out book as PDF bytes.
62///
63/// Fonts resolve through the registry that shaped the run and images
64/// through the table that sized them: the display structure names
65/// indexes, the tables own the files, and so the embedded subset has
66/// the outlines the shaper measured and the embedded image is the
67/// file the header was read from.
68///
69/// A book with no images passes [`Assets::none`].
70pub 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
124/// One line of a link as the annotation a viewer follows. An empty
125/// area is none.
126fn 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
135/// The top left corner of a box, on its page.
136fn destination(place: &PageBox) -> XyzDestination {
137    XyzDestination::new(place.page as usize, Point::from_xy(place.x, place.y))
138}
139
140/// One heading of the outline, and the headings under it.
141fn 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
149/// Every asset as a krilla image, indexed as the display structure
150/// indexes them.
151///
152/// The format is read off the bytes rather than off the url, since a
153/// url never has to name one: `/asset/8412` is a perfectly good
154/// one. PDF's `DCTDecode` is the JPEG stream
155/// itself, so a JPEG travels into the file as it arrived; the raster
156/// formats are decoded once, alpha channel and all.
157fn 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
172/// One image, in whichever format its own header says it is.
173///
174/// Interpolation is off, because an image placed at the size its own
175/// header asked for is already at the resolution it was made for.
176fn 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
187/// Every registered face as a krilla font, indexed by `font_id`.
188/// krilla subsets on write, so a face nothing draws with costs
189/// nothing.
190///
191/// A face off its family's default location embeds as that instance:
192/// the outlines a reader gets are the ones the shaper measured, not
193/// the whole axis.
194fn 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
215/// Book metadata as document metadata. `fleuron` is both creator and
216/// producer: the engine laid the pages out and wrote the file.
217///
218/// The creation date is the book's own `date`, because the engine
219/// reads no clock: a run that stamped the hour would write different
220/// bytes every time it ran, and two runs over one book are meant to
221/// be one file.
222fn 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
241/// A date as a book writes one: `2026-08-27`, and as much of
242/// `T14:30:00` after it as the book bothered with. Anything else is
243/// no date and no creation date.
244fn 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            // The glyphs are here; the features that chose them are
293            // for a painter that has to choose its own. Where the run
294            // was written is for a preview that follows a cursor; a
295            // page of a PDF does not move.
296            features: _,
297            origin: _,
298            pseudo_element: _,
299            // Each glyph carries its own x.
300            width: _,
301            // The page's items arrived in paint order, so the layer
302            // that put them in it is spent.
303            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            // What a reader selects and what a search matches is the
312            // manuscript, so a run that was transformed hands over
313            // what the author wrote rather than what was drawn.
314            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        // The transform is layout's decision, not the writer's: an
347        // image krilla draws into a unit square is translated to the
348        // corner layout put it at and scaled to the box layout sized
349        // for it.
350        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        // The box clips and the tile is drawn inside it, once or as
373        // many times as it takes to cover the box. Layout placed the
374        // first tile; the rest are that tile stepped by its own size.
375        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        // The even-odd rule is what leaves the inside of a ring empty.
426        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
463/// How far along its tangent each control point of a cubic curve sits,
464/// as a fraction of the radius, for the curve to follow a quarter
465/// ellipse.
466const KAPPA: f32 = 0.552_284_8;
467
468/// A box `[left, top, width, height]` with rounded corners, clockwise
469/// from the top left. Each corner is one cubic curve.
470fn 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
512/// Draws what `draw` draws at an eight-bit alpha, and writes nothing
513/// around it where the alpha is opaque.
514fn 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
524/// How many tiles one background is drawn as, whatever the arithmetic
525/// asks for. A tile a fraction of a point wide over a whole page
526/// would otherwise be drawn a million times, and nobody sees past the
527/// first few thousand.
528const TILES: usize = 4096;
529
530/// Where each copy of a repeated image is drawn, in paint order. One
531/// copy where the sheet asked for no repeat. Both boxes are
532/// `[left, top, width, height]`.
533fn 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    // How far back the tiling starts, and how many copies it takes to
540    // reach the far edge from there.
541    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
563/// What the next item is filled with. A page starts out filling in
564/// opaque black, so a run in opaque black writes no colour at all.
565fn 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
573/// An eight-bit alpha as the opacity krilla takes.
574fn opacity(alpha: u8) -> NormalizedF32 {
575    NormalizedF32::new(alpha as f32 / 255.0).unwrap_or(NormalizedF32::ONE)
576}
577
578/// Display-structure glyphs as krilla glyphs.
579///
580/// krilla walks a run by advances from its origin, so an absolute x
581/// becomes the gap to the glyph after it. The last glyph has no
582/// successor and takes the advance its font gives it — anything else
583/// would leave a spurious adjustment at the end of every run.
584/// Advances are in ems, hence the division by size.
585fn 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    // The run's origin need not be its first glyph's x; the gap
598    // shifts every glyph, since krilla measures the cursor from the
599    // origin.
600    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
622/// What a run's glyphs are read back as: one string, and the stretch
623/// of it each glyph stands for.
624struct Extracted<'a> {
625    text: &'a str,
626    ranges: Vec<std::ops::Range<usize>>,
627}
628
629/// The run as a reader gets it back. A run nothing transformed is
630/// read back as it was shaped; one that was has what the author
631/// wrote, and every glyph's range is taken through the run's map
632/// into it.
633fn 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
659/// A glyph's range, kept inside the string it indexes and on
660/// character boundaries: krilla slices the text with it to build the
661/// ToUnicode map, and a bad range there is a panic, not a wrong
662/// glyph.
663fn 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    /// The fixture map: a JPEG whose JFIF density is not 96dpi, so
683    /// its intrinsic size is not its pixel count.
684    const MAP: &[u8] = include_bytes!("../../../fixtures/images/plate.jpg");
685
686    /// The fixture ornament: a PNG whose ground is transparent, and
687    /// the same image as lossless WebP.
688    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    /// A book of one paragraph, with title metadata.
697    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    /// A book that opens with a heading, for a sheet to colour.
729    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    /// One hand-built page: the display structure a painter sees, without
771    /// going through layout.
772    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    /// An asset table over files a host handed over by name.
791    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    /// PDF bytes as inspectable text: content streams uncompressed,
800    /// bytes read as latin-1 so offsets survive.
801    fn readable(output: &LayoutOutput, metadata: &Metadata) -> String {
802        with_images(output, &Assets::none(), metadata)
803    }
804
805    /// The same, over images the host supplied.
806    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    /// The page content streams, joined: where the operators are.
834    /// The rest of a PDF is font and image bytes, and a byte pair
835    /// inside a subset is not an operator.
836    fn content(pdf: &str) -> String {
837        // The split is on `\nstream\n` rather than `stream\n`, which
838        // also matches the tail of `endstream` and takes the
839        // terminator with it.
840        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    /// The one glyph-positioning offset inside a showing op, in
852    /// 1000ths of an em: `[(…) 100 (…)] TJ`.
853    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    /// The page's trim size is the media box: krilla is told the size
859    /// the display structure gives, not a default.
860    #[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    /// Glyphs show where layout put them. A run whose second glyph
870    /// sits a hair off its font advance must come out as one showing
871    /// op with that offset in it — 1000ths of an em, positive leftwards
872    /// — not as two runs and not as font-advance spacing.
873    #[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        // 1pt tighter than the font advance, at 10pt: 100/1000 em,
906        // positive because PDF offsets pull the next glyph leftwards.
907        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    /// Acceptance: the writer paints a layered page in the order the
915    /// list is in, which is the order the layers put it in. The
916    /// preview painter walks the same list the same way, so the two
917    /// agree on what covers what.
918    #[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        // What a lowered block and a raised one come out as: the
923        // sort put the lower one first, and the writer keeps it
924        // there.
925        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    /// Rules paint as filled paths at the coordinates layout gave them.
960    #[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    /// Acceptance: the preview and the export agree about a rule
978    /// across a run. Both painters read the rects the display
979    /// structure holds and neither derives one of its own, so the
980    /// test is that the export paints a filled path at the
981    /// coordinates layout wrote.
982    #[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    /// Colour reaches the page: a heading the sheet coloured fills
1016    /// with the colour its run carries, a rule fills with its own,
1017    /// and a page in black writes no colour at all.
1018    #[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        // The channels the PDF writes, each over 255.
1038        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        // The fill is surface state, so the prose under the heading
1044        // has to set it back rather than inherit the red.
1045        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    /// Acceptance: text set in a colour with alpha paints at that
1076    /// alpha. The fill carries the colour, and a graphics state carries
1077    /// the alpha as its fill opacity.
1078    #[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    /// The face embeds as a subset: a tagged BaseFont, a FontFile2,
1103    /// and a stream far smaller than the face it came from.
1104    #[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    /// A JPEG is embedded as it arrived: PDF's `DCTDecode` is the
1124    /// JPEG stream itself, so re-encoding one would cost quality for
1125    /// nothing.
1126    #[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    /// The raster formats decode, and an alpha channel becomes the
1148    /// image's soft mask, which is what lets the paper, or a rule
1149    /// under it, show through.
1150    #[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    /// Four corners of one radius.
1190    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    /// A rounded tint fills a path whose corners are curves, and a ring
1204    /// fills the band between two outlines by the even-odd rule.
1205    #[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    /// Acceptance: a rounded chip on a run draws its corners in the
1245    /// export, the way a rounded block does. The chip reaches the
1246    /// painter as one rounded box, so the curves come out of the same
1247    /// path builder the preview reads the same box through.
1248    #[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    /// Acceptance: a rounded background image is clipped to the same
1283    /// curve as the tint under it.
1284    #[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    /// Acceptance: a tint with an alpha over the image behind a page
1318    /// leaves the image showing through. The image is drawn first, and
1319    /// the tint over it fills at a quarter of full opacity.
1320    #[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    /// An image with an alpha draws under a graphics state that
1364    /// carries the alpha as its opacity, and an opaque one sets none.
1365    #[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    /// Painters scale; they do not re-derive. An image layout sized
1397    /// down draws at the size layout gave it, not at the one its
1398    /// header declares.
1399    #[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        // krilla draws an image into the unit square, so the box is
1422        // the transform: the size layout asked for, and the corner it
1423        // asked for, measured up from the foot of the page.
1424        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    /// Acceptance: the preview and the export of a wrapped page
1431    /// agree.
1432    ///
1433    /// Both painters read the same display structure. The question is
1434    /// whether the export puts each item where that structure says.
1435    /// The image lands at the box its item carries. The line beside
1436    /// it starts at the x and the baseline its own item carries.
1437    #[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        // krilla draws an image into the unit square, so the box is
1488        // the transform. It carries the size and the corner of the
1489        // item, measured up from the foot of the page.
1490        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        // krilla writes text under a flip, so the line sits at the x
1502        // and the baseline the item carries.
1503        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    /// Acceptance: the preview and the export of a page with a
1511    /// relative block and an absolute block on it agree.
1512    ///
1513    /// Both painters read the same display structure. The export must
1514    /// put the lines of both blocks at the x and the baseline that
1515    /// their items carry.
1516    #[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            // krilla writes text under a flip, so the line sits at the
1575            // x and the baseline the item carries.
1576            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    /// An image nothing supplied is no draw item, and one warning
1585    /// names the url.
1586    #[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    /// Book metadata reaches the document information dictionary.
1617    #[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    /// A date the book does not write is no creation date, rather
1631    /// than the hour the run happened to start: the engine reads no
1632    /// clock, and two runs over one book are one file.
1633    #[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    /// Text is selectable because every glyph maps back to the
1642    /// characters it was shaped from: the ffi ligature is one glyph
1643    /// and three code points in the ToUnicode map.
1644    #[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    /// A transformed run is drawn in the letters the transform asked
1655    /// for and read back in the ones the author wrote: the glyphs are
1656    /// the capitals, and the ToUnicode map sends them to the source.
1657    #[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        // The drawn capitals map back to what was written: an `O`
1682        // that stands for an `o` is the whole of the difference.
1683        assert!(
1684            pdf.contains("<006F>"),
1685            "the ToUnicode map does not send the capitals to the source:\n{pdf}",
1686        );
1687    }
1688
1689    /// A first line transformed is drawn in the letters the transform
1690    /// asked for and read back in the ones the author wrote, and the
1691    /// rest of the paragraph is untouched.
1692    #[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    /// The file is structurally whole — header, page tree, cross
1729    /// reference, trailer — which is what `qpdf --check` reads.
1730    #[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    /// Two runs over one book produce byte-identical PDFs: nothing in
1745    /// the writer reads a clock or a hash of an address.
1746    #[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}