From 9372b25d0b387e9ebb72dc670488bfc544b1a4a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:07:53 -0400 Subject: [PATCH] M10: Create PDF from JPEG 2000 images (JP2 or codestream, embedded as JPXDecode); M1: looping /Prev chains test Co-Authored-By: Claude Opus 5.5 --- crates/cos/src/document.rs | 14 ++++++ crates/create/src/lib.rs | 80 +++++++++++++++++++++++++++++++- crates/create/src/tests.rs | 37 +++++++++++++++ crates/ui-egui/src/content_ui.rs | 6 +-- crates/ui-egui/src/create_ui.rs | 7 ++- crates/ui-egui/src/marks_ui.rs | 5 +- crates/ui-egui/src/stamps_ui.rs | 2 +- parity/acrobat-features.toml | 18 +++---- 8 files changed, 151 insertions(+), 18 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 94e1315a..279e8b9f 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -971,6 +971,20 @@ mod tests { assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// A /Prev chain that loops back on itself stops at the loop (and says so). + #[test] + fn looping_revision_chains_stop() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + // An update whose /Prev points at itself. + let at = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f \ntrailer << /Size 3 /Root 1 0 R /Prev {at} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("loops")), "{:?}", doc.repair_log()); + } + /// A hybrid-reference file: the classic table leaves object 3 out; the `/XRefStm` stream /// says it lives in object stream 4. #[test] diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 9e2492a5..9b0442a5 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -77,6 +77,82 @@ struct Embedded { dpi: (f64, f64), } +const JP2_SIGNATURE: &[u8] = &[0, 0, 0, 0x0C, b'j', b'P', b' ', b' ', 0x0D, 0x0A, 0x87, 0x0A]; + +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at + 4).map(|x| u32::from_be_bytes([x[0], x[1], x[2], x[3]])) +} + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|x| u16::from_be_bytes([x[0], x[1]])) +} + +/// The boxes of a JP2 box sequence: (type, payload). +fn jp2_boxes(b: &[u8]) -> Vec<([u8; 4], &[u8])> { + let mut out = Vec::new(); + let mut i = 0usize; + while i + 8 <= b.len() { + let len = be32(b, i).unwrap_or(0) as u64; + let ty = [b[i + 4], b[i + 5], b[i + 6], b[i + 7]]; + let (head, len) = match len { + 0 => (8, (b.len() - i) as u64), + 1 => match b.get(i + 8..i + 16) { + Some(x) => (16, u64::from_be_bytes([x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7]])), + None => break, + }, + n => (8, n), + }; + let end = i.saturating_add(len as usize).min(b.len()); + if len < head as u64 || end <= i { + break; + } + out.push((ty, &b[i + head..end])); + i = end; + } + out +} + +/// A JPEG 2000 image (JP2 file or raw codestream), embedded as is (`/JPXDecode`; the colour +/// space comes from the file). +fn jpx(name: &str, bytes: &[u8]) -> Result { + let bad = |m: &str| CreateError::Image(name.into(), m.into()); + let mut dpi = (72.0, 72.0); + let (w, h) = if bytes.starts_with(JP2_SIGNATURE) { + let header = + jp2_boxes(bytes).into_iter().find(|(t, _)| t == b"jp2h").map(|(_, p)| p).ok_or_else(|| bad("a JPEG 2000 file without a header"))?; + let inner = jp2_boxes(header); + let ihdr = inner.iter().find(|(t, _)| t == b"ihdr").map(|(_, p)| *p).ok_or_else(|| bad("a JPEG 2000 file without image size"))?; + // Capture resolution (pixels per metre), when given. + if let Some((_, res)) = inner.iter().find(|(t, _)| t == b"res ") + && let Some((_, r)) = jp2_boxes(res).into_iter().find(|(t, _)| t == b"resc" || t == b"resd") + && r.len() >= 10 + { + let part = |n: u16, d: u16, e: i8| if d == 0 { 0.0 } else { n as f64 / d as f64 * 10f64.powi(e as i32) * 0.0254 }; + let (v, hz) = ( + part(be16(r, 0).unwrap_or(0), be16(r, 2).unwrap_or(0), r[8] as i8), + part(be16(r, 4).unwrap_or(0), be16(r, 6).unwrap_or(0), r[9] as i8), + ); + if v > 1.0 && hz > 1.0 { + dpi = (hz, v); + } + } + (be32(ihdr, 4).ok_or_else(|| bad("bad image header"))?, be32(ihdr, 0).ok_or_else(|| bad("bad image header"))?) + } else { + // SIZ: Lsiz Rsiz Xsiz Ysiz XOsiz YOsiz … + let (x, y, xo, yo) = (be32(bytes, 8), be32(bytes, 12), be32(bytes, 16), be32(bytes, 20)); + match (x, y, xo, yo) { + (Some(x), Some(y), Some(xo), Some(yo)) if x > xo && y > yo => (x - xo, y - yo), + _ => return Err(bad("a damaged JPEG 2000 codestream")), + } + }; + if w == 0 || h == 0 || w > 100_000 || h > 100_000 { + return Err(bad("unusual JPEG 2000 image size")); + } + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("JPXDecode")); + Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi }) +} + fn jpeg(name: &str, bytes: &[u8]) -> Result { let bad = |m: &str| CreateError::Image(name.into(), m.into()); if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 { @@ -301,8 +377,10 @@ fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?]) } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?]) + } else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) { + Ok(vec![jpx(name, bytes)?]) } else { - Err(CreateError::Image(name.into(), "use a PNG, JPEG, TIFF, GIF or BMP image".into())) + Err(CreateError::Image(name.into(), "use a PNG, JPEG, JPEG 2000, TIFF, GIF or BMP image".into())) } } diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index f1bee363..e6407266 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -195,3 +195,40 @@ fn indexed_and_one_bit_images_decode() { rd.next_frame(&mut buf).unwrap(); assert_eq!(&buf[..12], &[255, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 255]); } + +#[test] +fn jpeg_2000_images_are_embedded_as_is() { + // A JP2 file: signature, ftyp, jp2h (ihdr 30 × 20, 3 components; resc 5906 px/m ≈ 150 dpi), + // and a (stub) codestream. + let bx = |ty: &[u8], payload: &[u8]| { + let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(ty); + v.extend_from_slice(payload); + v + }; + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&20u32.to_be_bytes()); + ihdr.extend_from_slice(&30u32.to_be_bytes()); + ihdr.extend_from_slice(&[0, 3, 7, 7, 0, 0]); + let mut resc = Vec::new(); + for _ in 0..2 { + resc.extend_from_slice(&5906u16.to_be_bytes()); + resc.extend_from_slice(&1u16.to_be_bytes()); + } + resc.extend_from_slice(&[0, 0]); + let jp2h = [bx(b"ihdr", &ihdr), bx(b"res ", &bx(b"resc", &resc))].concat(); + let file = [JP2_SIGNATURE.to_vec(), bx(b"ftyp", b"jp2 \0\0\0\0jp2 "), bx(b"jp2h", &jp2h), bx(b"jp2c", &[0xFF, 0x4F, 0xFF, 0x51])].concat(); + let doc = reopen(&from_images(&[("photo.jp2".into(), file.clone())]).unwrap()); + let p = &pages(&doc)[0]; + let m = media(p); + assert!((m[2] - 30.0 * 72.0 / 150.0).abs() < 0.2 && (m[3] - 20.0 * 72.0 / 150.0).abs() < 0.2, "{m:?}"); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "JPX can't be exported yet: {:?}", out.skipped); + // A raw codestream: the size comes from SIZ. + let mut siz = vec![0xFF, 0x4F, 0xFF, 0x51, 0, 41, 0, 0]; + for v in [64u32, 48, 0, 0] { + siz.extend_from_slice(&v.to_be_bytes()); + } + let doc = reopen(&from_images(&[("raw.j2k".into(), siz)]).unwrap()); + assert_eq!(media(&pages(&doc)[0])[2], 64.0); +} diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index a4c206f1..26f84f48 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -383,7 +383,7 @@ impl crate::PrintCraftApp { // Tests and automation set `save_override` and never see a native dialog. Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title("Choose an image") .pick_file(), }; @@ -408,7 +408,7 @@ impl crate::PrintCraftApp { Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title("Select Icon") .pick_file(), }; @@ -432,7 +432,7 @@ impl crate::PrintCraftApp { Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title("Replace image") .pick_file(), }; diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index cbd9f549..a1cef025 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use crate::PrintCraftApp; /// File types Open accepts besides PDF (converted on open). -pub const CONVERTIBLE: [&str; 9] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "txt", "text"]; +pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"]; fn is_image(bytes: &[u8]) -> bool { bytes.starts_with(&[0xFF, 0xD8]) @@ -14,6 +14,9 @@ fn is_image(bytes: &[u8]) -> bool { || bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") || bytes.starts_with(b"GIF8") + // JPEG 2000: a JP2 file or a raw codestream. + || bytes.starts_with(&[0, 0, 0, 0x0C, b'j', b'P', b' ', b' ']) + || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) // BMP: "BM" and a known header size (so text starting with "BM" stays text). || (bytes.starts_with(b"BM") && bytes.get(14..18).is_some_and(|h| matches!(u32::from_le_bytes([h[0], h[1], h[2], h[3]]), 12 | 40 | 52 | 56 | 108 | 124))) } @@ -129,7 +132,7 @@ impl PrintCraftApp { #[cfg(not(target_arch = "wasm32"))] { let Some(files) = rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title("Choose images") .pick_files() else { diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index b5c8960a..850ad26a 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -402,8 +402,9 @@ fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) { ui.horizontal(|ui| { #[cfg(not(target_arch = "wasm32"))] if ui.button("Browse…").clicked() - && let Some(p) = - rfd::FileDialog::new().add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]).pick_file() + && let Some(p) = rfd::FileDialog::new() + .add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .pick_file() { match std::fs::read(&p) { Ok(b) => d.file = Some((p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), std::sync::Arc::new(b))), diff --git a/crates/ui-egui/src/stamps_ui.rs b/crates/ui-egui/src/stamps_ui.rs index da38b352..08ea9df4 100644 --- a/crates/ui-egui/src/stamps_ui.rs +++ b/crates/ui-egui/src/stamps_ui.rs @@ -137,7 +137,7 @@ impl PrintCraftApp { Some(p) if [".png", ".jpg", ".pdf"].iter().any(|e| p.ends_with(e)) => Some(std::path::PathBuf::from(p)), Some(_) => None, None => rfd::FileDialog::new() - .add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"]) + .add_filter("PDF or image", &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title("Select a file for the stamp") .pick_file(), }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 84e7aa55..9f4f5c11 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -91,11 +91,11 @@ title = "Follow /Prev revision chains with loop protection" area = "A" tier = "P0" milestone = "M1" -status = "partial" -commands = [] -tools = [] -evidence = ["crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions"] -notes = "reading works; revision list API is M1.4" +status = "shipped" +commands = ["file.properties"] +tools = ["doc_revisions"] +evidence = ["crates/cos/src/document.rs::looping_revision_chains_stop", "crates/cos/src/document.rs::revision_ends_split_incremental_updates"] +notes = "/Prev chains followed newest first with a visited set (loops stop and are logged); revisions listed and opened (doc_revisions, doc_open_revision)" [[feature]] id = "core.stream-filters" @@ -6643,11 +6643,11 @@ title = "Create PDF from images (JPEG, PNG, TIFF, GIF, BMP, JPEG 2000)" area = "J" tier = "P0" milestone = "M10" -status = "partial" -commands = ["create.images", "create.file"] +status = "shipped" +commands = ["create.images"] tools = ["doc_create"] -evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::images_become_pages_at_their_resolution", "crates/create/src/tests.rs::bmp_gif_and_multi_page_tiff_images"] -notes = "PNG, JPEG, TIFF, GIF (first frame), BMP; JPEG 2000 pending" +evidence = ["crates/create/src/tests.rs::jpeg_2000_images_are_embedded_as_is", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"] +notes = "PNG, JPEG, TIFF (multi-page), GIF (first frame), BMP, and JPEG 2000 (JP2 or codestream, embedded as JPXDecode with its capture resolution)" [[feature]] id = "create.from-multipage-tiff"