designcraft/crates/pdf/src/tests.rs

461 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use designcraft_color::swatch::{ColorType, SwatchValue};
use designcraft_color::{Color, Swatch};
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Fill, Item, ItemId, ParaFormat, SpreadRef};
use designcraft_geom::Rect;
use crate::*;
fn doc_with_text(text: &str) -> Document {
let mut d = Document::new(&NewDocument { pages: 2, ..NewDocument::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, text, ParaFormat::default()).unwrap();
d
}
fn add_box(d: &mut Document, r: Rect, swatch: &str) {
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut b = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
b.fill = Fill::swatch(swatch);
d.insert_item(SpreadRef::Doc(0), b, None).unwrap();
}
fn uncompressed(d: &Document, opts: PdfOptions) -> String {
let bytes = export_pdf(d, &Cache::new(), &PdfOptions { compress: false, ..opts }).expect("export");
String::from_utf8_lossy(&bytes).into_owned()
}
/// Text of every page through hayro's interpreter (glyph → Unicode via ToUnicode / ActualText).
pub(crate) fn extract_text(bytes: &[u8]) -> Vec<String> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use hayro_syntax::Pdf;
struct Ex(String);
impl Device<'_> for Ex {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
match g.as_unicode() {
Some(hayro_cmap::BfString::Char(c)) => self.0.push(c),
Some(hayro_cmap::BfString::String(s)) => self.0.push_str(&s),
None => self.0.push('\u{FFFD}'),
}
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = Pdf::new(bytes.to_vec()).expect("parse");
let cache = InterpreterCache::new();
pdf.pages()
.iter()
.map(|page| {
let mut ctx =
Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
let mut ex = Ex(String::new());
interpret_page(page, &mut ctx, &mut ex);
ex.0
})
.collect()
}
#[test]
fn exports_valid_pdf_with_one_page_per_page() {
let d = doc_with_text("Hello DesignCraft");
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(bytes.starts_with(b"%PDF-1.7"));
let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("parse");
assert_eq!(pdf.pages().len(), 2);
}
#[test]
fn text_is_real_text() {
let d = doc_with_text("Hello DesignCraft — efficient affine");
let s = uncompressed(&d, PdfOptions::default());
assert!(s.contains("/FontFile2") || s.contains("/FontFile3"), "font embedded");
assert!(s.contains("/ToUnicode"));
assert!(s.contains(")Tj") || s.contains("]TJ"), "text operators");
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let text = extract_text(&bytes);
let flat: String = text[0].split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flat.contains("Hello"), "{flat}");
assert!(text[0].contains("DesignCraft"), "{text:?}");
assert!(text[0].contains("efficient") && text[0].contains("affine"), "ligatures map back: {text:?}");
}
#[test]
fn colours_keep_their_space() {
let mut d = doc_with_text("x");
add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "C=100 M=0 Y=0 K=0");
d.swatches.push(Swatch::color("Brand Red", Color::rgb(0.9, 0.1, 0.1)));
add_box(&mut d, Rect::new(200.0, 400.0, 300.0, 500.0), "Brand Red");
d.swatches.push(Swatch {
name: "PANTONE Test".into(),
value: SwatchValue::Color { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), color_type: ColorType::Spot },
locked: false,
named: true,
hidden: false,
});
add_box(&mut d, Rect::new(320.0, 400.0, 420.0, 500.0), "PANTONE Test");
let s = uncompressed(&d, PdfOptions::default());
assert!(s.contains("1 0 0 0 k"), "DeviceCMYK fill");
assert!(s.contains(" rg"), "DeviceRGB fill");
assert!(s.contains("/Separation") && s.contains("/PANTONE#20Test"), "spot as Separation");
}
/// [Paper] went into print PDFs as `1 1 1 rg`, DeviceRGB white.
#[test]
fn paper_prints_as_no_ink() {
let mut d = doc_with_text("x");
add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "[Paper]");
let s = uncompressed(&d, PdfOptions::default());
assert!(s.contains("0 0 0 0 k"), "CMYK, no ink");
assert!(!s.contains("1 1 1 rg"), "no RGB white");
// A web document keeps the paper colour.
d.settings.intent = designcraft_doc::Intent::Web;
assert!(uncompressed(&d, PdfOptions::default()).contains("1 1 1 rg"));
// PDF/X has a CMYK output intent whatever the document's intent.
assert!(!uncompressed(&d, PdfOptions { standard: Standard::PdfX4, ..Default::default() }).contains("1 1 1 rg"));
}
#[test]
fn boxes_bleed_and_marks() {
let mut d = doc_with_text("x");
d.settings.bleed = [9.0; 4];
let s = uncompressed(&d, PdfOptions { bleed: true, ..Default::default() });
assert!(s.contains("/TrimBox[9 9 621 801]"), "{}", &s[..s.len().min(4000)]);
assert!(s.contains("/MediaBox[0 0 630 810]"));
assert!(s.contains("/BleedBox[0 0 630 810]"));
let s = uncompressed(&d, PdfOptions { bleed: true, marks: Marks::ALL, pages: Some(vec![1]), ..Default::default() });
// margin = max(offset 6, bleed 9) + 18 + 6 = 33
assert!(s.contains("/MediaBox[0 0 678 858]"), "marks area");
assert!(s.contains("/All"), "registration colour");
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { marks: Marks::ALL, pages: Some(vec![1]), ..Default::default() }).unwrap();
let t = extract_text(&bytes);
assert_eq!(t.len(), 1);
assert!(t[0].contains("Page 2"), "{t:?}");
}
#[test]
fn spreads_and_ranges() {
let mut d = Document::new(&NewDocument { pages: 4, facing_pages: true, ..NewDocument::default() });
let _ = &mut d;
let n = d.page_count();
assert_eq!(n, 4);
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { spreads: true, ..Default::default() }).unwrap();
let pages = hayro_syntax::Pdf::new(bytes).unwrap().pages().len();
assert_eq!(pages, d.spreads.len());
assert_eq!(parse_page_range("1-2, 4", 4).unwrap(), vec![0, 1, 3]);
assert_eq!(parse_page_range("3-", 4).unwrap(), vec![2, 3]);
assert_eq!(parse_page_range("", 2).unwrap(), vec![0, 1]);
assert!(parse_page_range("5", 4).is_err());
assert!(parse_page_range("x", 4).is_err());
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![1, 2]), ..Default::default() }).unwrap();
assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 2);
assert_eq!(export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![9]), ..Default::default() }), Err(PdfError::BadPage(10)));
}
#[test]
fn standards() {
let d = doc_with_text("Archive me");
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() });
let r = r.expect("PDF/A-2b export");
assert!(String::from_utf8_lossy(&r.bytes).contains("pdfaid"));
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap();
assert!(r.bytes.starts_with(b"%PDF-1.6"));
// Output intent, identification and checks all in place; the update still reads.
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert!(crate::check_pdfx4(&r.bytes).is_empty());
let text = String::from_utf8_lossy(&r.bytes).to_string();
assert!(text.contains("/OutputIntents[") && text.contains("DesignCraft Generic CMYK"));
// A plain export fails the checks; both read with the same pages.
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(hayro_syntax::Pdf::new(r.bytes.clone()).unwrap().pages().len(), hayro_syntax::Pdf::new(plain.clone()).unwrap().pages().len());
assert!(crate::check_pdfx4(&plain).len() >= 3);
assert_eq!(Standard::parse("PDF/X-4"), Some(Standard::PdfX4));
assert_eq!(Standard::parse("pdfa-2b"), Some(Standard::PdfA2b));
}
#[test]
fn tables_export_cell_text() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "Before", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(9, 2, 2, 1, 0, 300.0);
t.cell_mut(0, 0).unwrap().text.insert(0, "Ink");
t.cell_mut(0, 0).unwrap().fill = "C=100 M=0 Y=0 K=0".into();
t.cell_mut(1, 1).unwrap().text.insert(0, "Plum");
t.cell_mut(2, 0).unwrap().text.insert(0, "Sunset");
d.story_mut(sid).unwrap().insert_table(6, t);
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let text = extract_text(&bytes).concat();
for w in ["Before", "Ink", "Plum", "Sunset"] {
assert!(text.contains(w), "{w} missing from {text:?}");
}
}
#[test]
fn drop_cap_text_extracts_in_order() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let para = designcraft_doc::ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..Default::default() };
let text = "Drop caps read in order with the rest of the paragraph, which runs on for a few lines beside the enlarged letters.";
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 236.0, 300.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap();
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let flat: String = extract_text(&bytes)[0].split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flat.starts_with("Drop caps read in order"), "{flat}");
}
/// A 4×2 CMYK TIFF: the left half 100% K (text in a scan), the right half C0 M100 Y100 K0.
fn cmyk_tiff() -> (Vec<u8>, Vec<u8>) {
let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]);
let px: Vec<u8> = (0..2).flat_map(|_| [k, k, red, red]).flatten().collect();
let mut b = Vec::new();
tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap().write_image::<tiff::encoder::colortype::CMYK8>(4, 2, &px).unwrap();
(b, px)
}
fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
let asset = designcraft_doc::AssetId(d.alloc());
let mime = designcraft_images::mime(&data).into();
d.assets.insert(
asset,
std::sync::Arc::new(designcraft_doc::Asset {
id: asset,
name: "scan.tif".into(),
mime,
link: None,
data: data.into(),
pixels: Some(px),
page: 0,
}),
);
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset,
size: (px.0 as f64, px.1 as f64),
xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0),
auto_fit: designcraft_doc::Fitting::FillProportionally,
fit_align: 4,
crop: [0.0; 4],
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
}
/// A placed image whose linked file wasn't found has no data: the warning says it is missing,
/// not that it could not be decoded.
#[test]
fn a_missing_linked_image_is_reported_as_missing() {
let mut d = doc_with_text("still here");
place(&mut d, Vec::new(), (64, 64));
if let Some(a) = d.assets.values_mut().next() {
std::sync::Arc::make_mut(a).link = Some("/gone/curtains.jpeg".into());
}
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("is missing (/gone/curtains.jpeg) and was skipped")), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("could not be decoded")), "{:?}", r.warnings);
}
/// krilla reads PNG, GIF and WebP lazily, so a damaged one passed the exporter's "does it decode"
/// check and failed the whole export when the PDF was written ("PDF writer error: … unexpected
/// end of file"). A cut-off download must be skipped with a warning, as a damaged TIFF is.
#[test]
fn a_cut_off_image_is_skipped_not_fatal() {
let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 37 + y * 11) as u8, (x * 5) as u8, (y * 91) as u8, 255]));
for format in [image::ImageFormat::Png, image::ImageFormat::Gif, image::ImageFormat::WebP] {
let mut whole = Vec::new();
px.write_to(&mut std::io::Cursor::new(&mut whole), format).unwrap();
let cut = whole[..whole.len() * 9 / 10].to_vec();
let mut d = doc_with_text("still here");
place(&mut d, cut, (64, 64));
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap_or_else(|e| panic!("{format:?}: {e}"));
assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "{format:?}: {:?}", r.warnings);
assert!(image_xobjects(&r.bytes).is_empty(), "{format:?}");
assert!(extract_text(&r.bytes).concat().contains("still"), "{format:?}: the rest of the page is exported");
// The undamaged file still goes in.
let mut d = doc_with_text("x");
place(&mut d, whole, (64, 64));
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(r.warnings.is_empty(), "{format:?}: {:?}", r.warnings);
assert_eq!(image_xobjects(&r.bytes).len(), 1, "{format:?}");
}
}
/// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples.
fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
use hayro_syntax::object::Name;
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
pdf.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| {
let cs = s.dict().get::<Name>("ColorSpace").map_or("?".to_string(), |n| n.as_str().to_string());
(cs, s.decoded().expect("decode").into_owned())
})
.collect()
}
/// A placed CMYK TIFF went into PDFs as DeviceRGB (#35): 100% K became RGB black, which the
/// press separates as four-colour black.
#[test]
fn cmyk_tiff_keeps_its_inks() {
let (tif, px) = cmyk_tiff();
let mut d = doc_with_text("x");
place(&mut d, tif, (4, 2));
for standard in [Standard::None, Standard::PdfX4] {
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
let images = image_xobjects(&r.bytes);
assert_eq!(images, vec![("DeviceCMYK".to_string(), px.clone())], "{standard:?}");
assert_eq!(images[0].1[..4], [0, 0, 0, 255], "K only");
}
}
/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec<u8> {
let img =
image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 128, if alpha && x == 0 && y == 0 { 0 } else { 255 }]));
let img = if fmt == image::ImageFormat::Jpeg {
image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(img).to_rgb8())
} else {
image::DynamicImage::ImageRgba8(img)
};
let mut b = std::io::Cursor::new(Vec::new());
img.write_to(&mut b, fmt).unwrap();
b.into_inner()
}
/// Each image XObject's /Interpolate value (`None` when absent).
fn interpolate_flags(bytes: &[u8]) -> Vec<Option<bool>> {
use hayro_syntax::object::Name;
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
pdf.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| s.dict().get::<bool>("Interpolate"))
.collect()
}
/// PDF/A-2b rejected placed PNG, JPEG, GIF and WebP images (#71): they were written with
/// /Interpolate true, which PDF/A forbids. Ordinary exports keep interpolation.
#[test]
fn archival_exports_keep_placed_images_without_interpolation() {
use image::ImageFormat as F;
let mut d = doc_with_text("x");
for (fmt, alpha) in [(F::Png, true), (F::Png, false), (F::Jpeg, false), (F::Gif, true), (F::WebP, true)] {
place(&mut d, raster(fmt, alpha), (8, 8));
}
for compress_images in [false, true] {
let opts = PdfOptions { compress_images, created: Some(1_700_000_000), ..Default::default() };
let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..opts.clone() });
let a = a.unwrap_or_else(|e| panic!("PDF/A-2b with images (compress {compress_images}): {e:?}"));
let flags = interpolate_flags(&a.bytes);
assert!(flags.len() >= 5, "every placed image is in the PDF/A file: {flags:?}");
assert!(flags.iter().all(|f| *f != Some(true)), "{flags:?}");
let plain = export_pdf(&d, &Cache::new(), &opts).unwrap();
let flags = interpolate_flags(&plain);
assert!(flags.len() >= 5, "{flags:?}");
assert!(flags.contains(&Some(true)), "ordinary exports interpolate: {flags:?}");
}
}
/// What a page draws: each glyph (its text and outline bounds) and each filled path's bounds.
fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use kurbo::Shape as _;
#[derive(Default)]
struct Rec(Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>);
impl Device<'_> for Rec {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, p: &kurbo::BezPath, xf: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {
self.1.push((xf * p.clone()).bounding_box());
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
let text = match g.as_unicode() {
Some(hayro_cmap::BfString::Char(c)) => c.to_string(),
Some(hayro_cmap::BfString::String(s)) => s,
None => String::new(),
};
let bounds = match g {
Glyph::Outline(o) => o.outline().bounding_box(),
Glyph::Type3(_) => kurbo::Rect::ZERO,
};
self.0.push((text, bounds));
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
let cache = InterpreterCache::new();
let page = &pdf.pages()[0];
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
let mut rec = Rec::default();
interpret_page(page, &mut ctx, &mut rec);
(rec.0, rec.1)
}
#[test]
fn missing_glyphs_print_as_boxes() {
// Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph
// (which PDF/A and PDF/UA forbid).
let boxes = |paths: &[kurbo::Rect]| paths.iter().filter(|r| r.width() > 1.0 && r.width() < 12.0 && r.height() > 3.0 && r.height() < 12.0).count();
let d = doc_with_text("a語b");
assert!(!d.settings.glyph_fallback);
let (glyphs, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["a", "b"], "{glyphs:?}");
assert_eq!(boxes(&paths), 1, "{paths:?}");
let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..Default::default() });
assert!(a.is_ok(), "PDF/A takes the box: {:?}", a.err());
// A font whose .notdef is empty: the box DesignCraft draws for it.
const FAMILY: &str = "DC Test PDF Empty Notdef";
let font = designcraft_fonts::testing::font_mapping(FAMILY, &[('a', 'a'), ('b', 'b')]).unwrap();
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::with_empty_notdef(font).unwrap());
let mut d = doc_with_text("a語b");
let sid = *d.stories.keys().next().unwrap();
d.story_mut(sid).unwrap().format_chars(0.."a語b".len(), |f| f.over.font_family = Some(FAMILY.into()));
let (glyphs, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
assert_eq!(glyphs.iter().map(|g| g.0.as_str()).collect::<Vec<_>>(), ["a", "b"], "{glyphs:?}");
assert_eq!(boxes(&paths), 1, "{paths:?}");
}
#[test]
fn column_rules_are_drawn() {
let rules = |on: bool| {
let mut d = doc_with_text("Column text");
let fid = d.stories.values().next().unwrap().frames[0];
let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;
o.columns = 3;
o.gutter = 12.0;
o.inset = [0.0; 4];
o.column_rule = on;
o.column_rule_weight = 2.0;
let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
paths.into_iter().filter(|r| (r.width() - 2.0).abs() < 0.01 && (r.height() - 264.0).abs() < 0.01).map(|r| r.center().x).collect::<Vec<_>>()
};
// 540 pt wide in three columns with 12 pt gutters: columns of 172 pt from x 36.
let xs = rules(true);
assert_eq!(xs.len(), 2, "{xs:?}");
assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}");
assert!(rules(false).is_empty());
}