Files
designcraft/crates/pdf/src/tests.rs
T
aiongg 778a270f48 Merge smallcap-tounicode: PDF: small caps and substituted figures keep their text
Conflicts:
- crates/pdf/src/text.rs run_text: the branch's two generated_text
  branches first, then #392's kashida branch (no text), then the cmap
  fallback for other inserted glyphs.
- crates/compose/src/lib.rs: next's kashida PlacedGlyph (with dx) gets
  generated_text: None.
- crates/compose/src/shape.rs hyphen_after: next's rendered_char/moji
  resets and generated_text = None.
- crates/compose/src/ruby.rs: next's ruby module (#363); the branch's
  edit to the old ruby set() has nothing to apply to.
- crates/pdf/src/tests.rs: tests from both.

Also, so the merge compiles: next's ruby (ruby.rs annotate) and kenten
(kenten.rs shape_mark) PlacedGlyph literals take generated_text: None;
they map back through the font like other inserted glyphs. Kanji page
numbers, composite-font note numbers and fallback-face label segments
go through shape_segment's replacement and list labels through
prepend_label, so they carry their generated text.
2026-10-10 23:47:37 +00:00

539 lines
26 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,
data: data.into(),
pixels: Some(px),
..Default::default()
}),
);
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],
wrap: Default::default(),
});
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 exports_embedded_text_from_footnotes() {
use designcraft_doc::{AnchorPosition, AnchoredObject};
use std::sync::Arc;
let mut d = doc_with_text("Body");
let body = *d.stories.keys().next().unwrap();
let (fid, _) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 100.0, 40.0), d.default_layer(), "Embedded", ParaFormat::default()).unwrap();
let item = d.item(fid).unwrap().clone();
d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid);
let story = d.story_mut(body).unwrap();
story.insert_note(4, "Note", ParaFormat::default());
Arc::make_mut(&mut story.notes[0]).text.insert_object(0, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 }));
d.check().unwrap();
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let text = extract_text(&bytes).join("");
assert!(text.contains("Embedded"), "{text}");
}
#[test]
fn fills_open_contours_without_changing_strokes() {
let mut d = Document::new(&NewDocument::default());
add_box(&mut d, Rect::new(40.0, 50.0, 62.0, 67.0), designcraft_color::swatch::BLACK);
let id = d.spreads[0].items[0].id;
d.item_mut(id).unwrap().path.subpaths[0].closed = false;
let (_, paths) = drawn(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
assert!(paths.iter().any(|r| (r.width() - 22.0).abs() < 0.01 && (r.height() - 17.0).abs() < 0.01), "{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());
}
#[test]
fn ruby_overprint_is_reported_as_not_exported() {
use designcraft_doc::cjk_settings::AdornmentOverprint;
let mut d = doc_with_text("漢字です");
let sid = *d.stories.keys().next().unwrap();
let clean = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(!clean.warnings.iter().any(|w| w.contains("overprint")), "{:?}", clean.warnings);
d.story_mut(sid).unwrap().format_chars(0.."漢字".len(), |f| {
f.over.ruby = Some("かんじ".into());
f.over.ruby_overprint_fill = Some(AdornmentOverprint::On);
});
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("overprint is not exported yet")), "{:?}", r.warnings);
}
#[test]
fn small_caps_and_small_cap_figures_extract_as_typed() {
// Source Sans 3 substitutes letters and figures under `smcp` / `c2sc` (`one` → `one.s`),
// in typed text, in a page number and in a list label alike (each with its own figures, so
// one's mapping can't stand in for another's).
use designcraft_doc::{Capitalization, ListType, story::PAGE_NUMBER};
for caps in [Capitalization::SmallCaps, Capitalization::OpenTypeAllSmallCaps] {
let text = format!("Abc 234 x{PAGE_NUMBER}\nItem");
let mut d = doc_with_text(&text);
let sid = *d.stories.keys().next().unwrap();
let st = d.story_mut(sid).unwrap();
st.format_paras(0..text.len(), |f| {
f.chars.font_family = Some("Source Sans 3".into());
f.chars.capitalization = Some(caps);
});
st.format_paras(text.len() - 1..text.len(), |f| {
f.para.list_type = Some(ListType::Numbers);
f.para.start_at = Some(Some(5));
});
let got = extract_text(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()).concat();
assert!(!got.contains('\u{FFFD}'), "{caps:?}: {got:?}");
assert_eq!(got.split_whitespace().collect::<Vec<_>>(), ["Abc", "234", "x15.Item"], "{caps:?}: {got:?}");
}
// All small caps draws `A` and `a` with one glyph, which one ToUnicode entry can't cover: the
// second spelling goes out as ActualText (which this extractor doesn't read).
let mut d = doc_with_text("Aa");
let sid = *d.stories.keys().next().unwrap();
d.story_mut(sid).unwrap().format_chars(0..2, |f| {
f.over.font_family = Some("Source Sans 3".into());
f.over.capitalization = Some(Capitalization::OpenTypeAllSmallCaps);
});
assert!(uncompressed(&d, PdfOptions::default()).contains("/ActualText"));
}