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:
parent
80a8087ebc
commit
9372b25d0b
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue