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 { 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>) {} 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>, _: 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::>().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::>().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, Vec) { let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]); let px: Vec = (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::(4, 2, &px).unwrap(); (b, px) } fn place(d: &mut Document, data: Vec, 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)> { 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::("Subtype").is_some_and(|n| n.as_str() == "Image")) .map(|s| { let cs = s.dict().get::("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 { 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> { 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::("Subtype").is_some_and(|n| n.as_str() == "Image")) .map(|s| s.dict().get::("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) { 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); impl Device<'_> for Rec { fn set_soft_mask(&mut self, _: Option>) {} 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>, _: 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::>(), ["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::>(), ["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::>() }; // 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()); }