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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:07:53 -04:00
parent 80a8087ebc
commit 9372b25d0b
8 changed files with 151 additions and 18 deletions

View File

@ -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]

View File

@ -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<u32> {
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<u16> {
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<Embedded, CreateError> {
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<Embedded, CreateError> {
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<Vec<Embedded>, 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()))
}
}

View File

@ -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);
}

View File

@ -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(),
};

View File

@ -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 {

View File

@ -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))),

View File

@ -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(),
};

View File

@ -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"