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)
This commit is contained in:
aiongg authored and GitHub committed 2026-10-10 01:13:48 -04:00
1 parent 3033c8cc64
commit fe16e00e37
9 files changed
+169 -14

No files matched your search

+7
View File
@@ -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));
+29
View File
@@ -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<_>>(), 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::<Vec<_>>().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)]
+1
View File
@@ -1379,6 +1379,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
("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) {
+1
View File
@@ -54,6 +54,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
}
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))?;
+1
View File
@@ -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) {
+54 -1
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<Value> {
let data: Vec<Vec<String>> = 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<designcraft_doc::Table> {
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");
}
}
}
+17 -13
View File
@@ -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<u32, Arc<HashMap<u32, char>>>,
/// 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<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
@@ -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<Vec<u8>> {
Some(out)
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>) -> Option<Image> {
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, interpolate: bool) -> Option<Image> {
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<Image> {
fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option<Image> {
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<Image> {
fn recompress(data: &[u8], interpolate: bool) -> Option<Image> {
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<Image> {
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()
}
+49
View File
@@ -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<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;
+10
View File
@@ -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 };