From fe16e00e3753714cef96818fdc2d6e8a373d431b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:13:48 -0400 Subject: [PATCH] PDF/A keeps placed images; right-to-left tables and columns (#128) * PDF: PDF/A exports keep placed PNG, JPEG, GIF and WebP images by writing them without interpolation (#71) * Tables: new tables take their paragraph's direction, and the Table panel sets a table's direction (#100) * Text: stories made in a right-to-left-bound document fill their columns from the right (#100) --- crates/doc/src/edit.rs | 7 ++++ crates/engine/src/cmd/layout.rs | 29 +++++++++++++++ crates/engine/src/cmd/object.rs | 1 + crates/engine/src/cmd/path_type.rs | 1 + crates/engine/src/cmd/place_text.rs | 1 + crates/engine/src/cmd/table.rs | 55 ++++++++++++++++++++++++++++- crates/pdf/src/export.rs | 30 +++++++++------- crates/pdf/src/tests.rs | 49 +++++++++++++++++++++++++ crates/ui-egui/src/panels/table.rs | 10 ++++++ 9 files changed, 169 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 188f514..3bb8271 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -171,11 +171,18 @@ impl Document { Ok(removed) } + /// The column direction a new story starts with: right to left in a document whose binding is + /// right to left (Document Setup), else left to right. Stories already made keep theirs. + pub fn new_story_direction(&self) -> crate::TextDirection { + if self.settings.right_to_left_binding { crate::TextDirection::RightToLeft } else { crate::TextDirection::LeftToRight } + } + /// Create a text frame with a new (or given) story at `rect` on spread `r`. pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> { let id = ItemId(self.alloc()); let sid = StoryId(self.alloc()); let mut story = Story::with_text(sid, text, para); + story.direction = self.new_story_direction(); story.frames.push(id); self.stories.insert(sid, Arc::new(story)); let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2a1b075..bd53207 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -759,6 +759,35 @@ mod setup_tests { let d = &s.doc().unwrap().doc; assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); } + + /// Multi-column frames filled the left column first in a right-to-left document (#100): + /// stories made in a right-to-left-bound document start their columns on the right. + #[test] + fn new_stories_follow_the_binding_direction() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); + for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); + let cs = s.cache.get(&st.doc, sid, None); + let fr = &cs.frames[0]; + let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); + assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); + assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); + // An empty frame given text content starts a story too. + let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); + s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); + let d = &s.doc().unwrap().doc; + let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); + } + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 40802d5..dc456a1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1379,6 +1379,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result { ("text", Some(Content::Unassigned | Content::Graphic(_))) => { let sid = StoryId(d.alloc()); let mut st = Story::new(sid); + st.direction = d.new_story_direction(); st.frames.push(*id); d.stories.insert(sid, Arc::new(st)); if let Some(it) = d.item_mut(*id) { diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 883f0c2..77bbf87 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -54,6 +54,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let sid = StoryId(d.alloc()); let mut st = Story::with_text(sid, &text, Default::default()); + st.direction = d.new_story_direction(); st.frames = vec![id]; d.stories.insert(sid, std::sync::Arc::new(st)); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 39805bf..766811b 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -200,6 +200,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - Some(_) => { let sid = StoryId(d.alloc()); let mut ns = Story::new(sid); + ns.direction = d.new_story_direction(); ns.frames = vec![fid]; d.stories.insert(sid, std::sync::Arc::new(ns)); if let Some(it) = d.item_mut(fid) { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2f925c..3bb188b 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -372,6 +372,14 @@ fn column_width(s: &Session, t: &TextSel) -> f64 { spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0) } +/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style +/// chain, then local overrides). A table created there takes it. +fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection { + d.story(story) + .and_then(|st| st.paras.get(st.para_at(offset))) + .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) +} + fn insert(s: &mut Session, p: &Value) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { @@ -385,6 +393,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { s.edit(|d, sel| { let id = d.alloc(); let mut table = Table::new(id, rows, cols, header, footer, width); + table.options.direction = para_direction(d, t.story, t.range().start); let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?; // Cells start with the paragraph format at the caret. let pf = st.paras[st.para_at(t.range().start)].clone(); @@ -410,6 +419,7 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); + let direction = para_direction(d, t.story, t.range().start); let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?; let ranges = st.para_ranges(); let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end)); @@ -420,7 +430,8 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); - let table = Table::from_strings(id, &data, width, &pf, &cf); + let mut table = Table::from_strings(id, &data, width, &pf, &cf); + table.options.direction = direction; st.delete(a..b); let anchor = st.insert_table(a, table); *sel = Selection::text(TextSel::caret(t.story, anchor)); @@ -1435,4 +1446,46 @@ mod arabic_tests { assert_eq!(t.ncols(), 2); assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft); } + + /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's + /// text selected. Returns the session and the story id. + fn styled_frame(direction: &str, text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + (s, r["story"].as_u64().unwrap()) + } + + fn only_table(s: &Session) -> std::sync::Arc { + s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone() + } + + /// Text converted to a table in a right-to-left paragraph laid its columns out left to right + /// (#100): the first column belongs on the right. + #[test] + fn tables_take_the_direction_of_their_paragraph() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, RightToLeft); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None); + let placed = &cs.frames[0].tables[0]; + let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect); + assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}"); + + let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, LeftToRight); + + for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let (mut s, _) = styled_frame(direction, "x"); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph"); + } + } } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69..095ea77 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -154,6 +154,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: archival.is_some(), + interpolate: archival.is_none(), tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, @@ -327,6 +328,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: false, + interpolate: true, tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, @@ -421,6 +423,8 @@ pub(crate) struct Exporter<'a> { pub reverse_cmaps: HashMap>>, /// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet). pub rgb_only: bool, + /// Ask viewers to smooth upscaled images (`/Interpolate`). Off for PDF/A, which forbids it. + interpolate: bool, /// Tagged PDF: the structure in reading order (stories gather their frames' content). tags: Vec, story_tags: HashMap>, @@ -1168,21 +1172,21 @@ impl Exporter<'_> { let data = asset.data.clone(); let fmt = image::guess_format(&data).ok(); let img = match fmt { - Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(), + Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(), // A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print // as four-colour black). PDF/A exports are RGB only. - Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) { + Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) { Some(img) => Some(img), None => { self.warn(format!( "{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)", asset.name )); - lossless(&data, fmt) + lossless(&data, fmt, self.interpolate) } }, - _ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)), - _ => lossless(&data, fmt), + _ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)), + _ => lossless(&data, fmt, self.interpolate), }; if img.is_none() { self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); @@ -1276,11 +1280,11 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { Some(out) } -fn lossless(data: &Arc>, fmt: Option) -> Option { +fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option { let direct = match fmt { - Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(), - Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), true).ok(), - Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), true).ok(), + Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(), _ => None, }; direct.or_else(|| { @@ -1320,18 +1324,18 @@ impl krilla::image::CustomImage for CmykImage { } /// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK. -fn cmyk_tiff(data: &[u8]) -> Option { +fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option { let r = designcraft_images::decode_cmyk_tiff(data)?; // krilla panics when the channel lengths don't match the size. let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?; if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) { return None; } - Image::from_custom(CmykImage(Arc::new(r)), true).ok() + Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok() } /// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -fn recompress(data: &[u8]) -> Option { +fn recompress(data: &[u8], interpolate: bool) -> Option { let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -1340,5 +1344,5 @@ fn recompress(data: &[u8]) -> Option { let mut buf = Vec::new(); let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90); image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; - Image::from_jpeg(buf.into(), true).ok() + Image::from_jpeg(buf.into(), interpolate).ok() } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1771c4e..83744a3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -271,6 +271,55 @@ fn cmyk_tiff_keeps_its_inks() { } } +/// 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; diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 3f2d871..7a1cb3a 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -191,6 +191,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } }); divider(ui); + // Column order: right to left puts the first column on the right. + let rtl = info["options"]["direction"] == "rightToLeft"; + ui.horizontal(|ui| { + caption(ui, crate::i18n::tr(&app.ui.language, "Direction")); + for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { + if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on { + run(app, "table.options", json!({"direction": dir})); + } + } + }); let mut alt = !info["options"]["altRows"].is_null(); if ui.checkbox(&mut alt, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Alternating Row Fills"))).changed() { let v = if alt { json!({"first": 1, "firstColor": "[Black]", "firstTint": 0.1, "next": 1, "nextColor": "[None]"}) } else { Value::Null };