O: PDF/X-4 — output intent with our own CMYK ICC profile, identification, checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
368767ee5d
commit
e95f562425
|
|
@ -96,6 +96,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Kashida justification, caret movement in visual order and digit substitution aren't there yet.
|
||||
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish and Japanese menu titles, common menu items and panel names (our own translations); dialogs and the rest of the UI stay English.
|
||||
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
|
||||
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
|
||||
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
|
||||
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
|
||||
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
|
||||
|
|
@ -153,7 +154,7 @@ four agents in parallel on separate crates:
|
|||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Open P0 (3: PDF/X-4 output intent and validation, EPS place, remaining Preferences sections) | 12 h |
|
||||
| Open P0 (3: full PDF/X-4 validation against a certified checker, EPS place, remaining Preferences sections) | 9 h |
|
||||
| Open P1 (5: New Window, colour management, language support, UI keyboard navigation, …) | 15 h |
|
||||
| Open P2 (37: 12 missing — spread rotation, page transitions, vertical/RTL type, math, video/audio, split window, localization, flattener, placed-file layers — and 25 partial) | 110 h |
|
||||
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
//! An ICC output profile of our Generic CMYK press model (for PDF/X output intents): lut16
|
||||
//! tables sampled from the model — CMYK → Lab from the forward model, Lab → CMYK from its
|
||||
//! inverse (separation with black generation and the ink limit). The profile is ours, built in
|
||||
//! code; it describes our model, not a measured press.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use moxcms::{
|
||||
ColorProfile, DataColorSpace, LocalizableString, LutDataType, LutStore, LutType, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
|
||||
RenderingIntent,
|
||||
};
|
||||
|
||||
use super::Intent;
|
||||
use super::lab::Lab;
|
||||
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
|
||||
/// ICC v2 legacy 16-bit Lab encoding (lut16Type).
|
||||
fn enc_lab(l: Lab) -> [u16; 3] {
|
||||
let q = |v: f32| v.round().clamp(0.0, 65535.0) as u16;
|
||||
[q(l.l * 652.8), q((l.a + 128.0) * 256.0), q((l.b + 128.0) * 256.0)]
|
||||
}
|
||||
|
||||
fn dec_lab(v: [u16; 3]) -> Lab {
|
||||
Lab::new(v[0] as f32 / 652.8, v[1] as f32 / 256.0 - 128.0, v[2] as f32 / 256.0 - 128.0)
|
||||
}
|
||||
|
||||
fn lut(inputs: u8, outputs: u8, grid: usize, clut: Vec<u16>) -> LutWarehouse {
|
||||
let ident = |n: u8| LutStore::Store16((0..n).flat_map(|_| [0u16, 65535]).collect());
|
||||
LutWarehouse::Lut(LutDataType {
|
||||
num_input_channels: inputs,
|
||||
num_output_channels: outputs,
|
||||
num_clut_grid_points: grid as u8,
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
num_input_table_entries: 2,
|
||||
num_output_table_entries: 2,
|
||||
input_table: ident(inputs),
|
||||
clut_table: LutStore::Store16(clut),
|
||||
output_table: ident(outputs),
|
||||
lut_type: LutType::Lut16,
|
||||
})
|
||||
}
|
||||
|
||||
fn text(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
}
|
||||
|
||||
/// The Generic CMYK model as an ICC output profile (built once).
|
||||
pub fn generic_cmyk_icc() -> &'static [u8] {
|
||||
static P: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
P.get_or_init(|| {
|
||||
let g = super::generic();
|
||||
let step = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
// CMYK → Lab (media-relative), first input channel varying slowest.
|
||||
let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3);
|
||||
for c in 0..A2B_GRID {
|
||||
for m in 0..A2B_GRID {
|
||||
for y in 0..A2B_GRID {
|
||||
for k in 0..A2B_GRID {
|
||||
let lab = g.to_lab_rel([step(c, A2B_GRID), step(m, A2B_GRID), step(y, A2B_GRID), step(k, A2B_GRID)]);
|
||||
a2b.extend(enc_lab(lab));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lab → CMYK through the model's separation.
|
||||
let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4);
|
||||
let e = |i: usize| (i as f32 * 65535.0 / (B2A_GRID - 1) as f32).round() as u16;
|
||||
for l in 0..B2A_GRID {
|
||||
for a in 0..B2A_GRID {
|
||||
for b in 0..B2A_GRID {
|
||||
let lab = dec_lab([e(l), e(a), e(b)]);
|
||||
let cmyk = g.from_lab(lab, Intent::RelativeColorimetric, true);
|
||||
b2a.extend(cmyk.map(|v| (v.clamp(0.0, 1.0) * 65535.0).round() as u16));
|
||||
}
|
||||
}
|
||||
}
|
||||
let a2b = lut(4, 3, A2B_GRID, a2b);
|
||||
let b2a = lut(3, 4, B2A_GRID, b2a);
|
||||
let mut p = ColorProfile::default();
|
||||
p.pcs = DataColorSpace::Lab;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.rendering_intent = RenderingIntent::RelativeColorimetric;
|
||||
let d50 = moxcms::Xyzd { x: 0.9642, y: 1.0, z: 0.8249 };
|
||||
p.white_point = d50;
|
||||
p.media_white_point = Some(d50);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a);
|
||||
p.description = text(super::GENERIC_CMYK);
|
||||
p.copyright = text("No copyright; made by DesignCraft from its parametric press model. Use freely.");
|
||||
p.encode().unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generic_cmyk_profile_round_trips() {
|
||||
let bytes = generic_cmyk_icc();
|
||||
assert!(bytes.len() > 1000);
|
||||
assert_eq!(&bytes[36..40], b"acsp");
|
||||
assert_eq!(&bytes[16..20], b"CMYK");
|
||||
assert_eq!(&bytes[12..16], b"prtr");
|
||||
// Read back: CMYK → sRGB through the profile is close to the model.
|
||||
let p = super::super::icc::IccProfile::from_bytes(None, bytes).expect("parses");
|
||||
let g = super::super::generic();
|
||||
for cmyk in [[0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0], [0.2, 0.3, 0.1, 0.5]] {
|
||||
let via = p.to_srgb(&cmyk, Intent::RelativeColorimetric).expect("converts");
|
||||
let model = g.to_srgb(cmyk, false);
|
||||
for i in 0..3 {
|
||||
assert!((via[i] - model[i]).abs() < 0.04, "{cmyk:?}: {via:?} vs {model:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
pub mod generic;
|
||||
pub mod icc;
|
||||
pub mod icc_out;
|
||||
pub mod lab;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
|
|
|||
|
|
@ -102,15 +102,10 @@ pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
|
|||
/// Like [`export_pdf`], also returning warnings about approximated or dropped features.
|
||||
pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
|
||||
let sheets = sheets(doc, opts)?;
|
||||
let mut warnings = Vec::new();
|
||||
let warnings = Vec::new();
|
||||
let (version, archival) = match opts.standard {
|
||||
Standard::None => (PdfVersion::Pdf17, None),
|
||||
Standard::PdfX4 => {
|
||||
warnings.push(
|
||||
"PDF/X-4: the output intent and PDF/X identification are not written yet; the file is PDF 1.6 with trim and bleed boxes".to_string(),
|
||||
);
|
||||
(PdfVersion::Pdf16, None)
|
||||
}
|
||||
Standard::PdfX4 => (PdfVersion::Pdf16, None),
|
||||
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
|
||||
};
|
||||
let mut cb = ConfigurationBuilder::new().with_version(version);
|
||||
|
|
@ -203,8 +198,18 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
|||
if opts.tagged {
|
||||
pdf.set_tag_tree(ex.tag_tree());
|
||||
}
|
||||
let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut warnings = ex.warnings;
|
||||
if opts.standard == Standard::PdfX4 {
|
||||
// Output intent and PDF/X identification, then check the result.
|
||||
match crate::pdfx::make_pdfx4(&bytes, &title) {
|
||||
Some(b) => bytes = b,
|
||||
None => warnings.push("PDF/X-4: the output intent couldn't be added".into()),
|
||||
}
|
||||
for issue in crate::pdfx::check_pdfx4(&bytes) {
|
||||
warnings.push(format!("PDF/X-4: {issue}"));
|
||||
}
|
||||
}
|
||||
warnings.dedup();
|
||||
Ok(ExportReport { bytes, pages: sheets.len(), warnings })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ mod links;
|
|||
mod marks;
|
||||
mod text;
|
||||
|
||||
mod pdfx;
|
||||
mod transitions;
|
||||
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads};
|
||||
pub use pdfx::{check_pdfx4, make_pdfx4};
|
||||
pub use transitions::add_transitions;
|
||||
|
||||
/// PDF standard to target.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
//! PDF/X-4: the output intent (our Generic CMYK profile), the PDF/X version in the document
|
||||
//! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a
|
||||
//! check of the requirements this exporter is responsible for.
|
||||
|
||||
use crate::transitions::{find, int_after, object, rfind};
|
||||
|
||||
/// Escape a PDF literal string.
|
||||
fn lit(s: &str) -> String {
|
||||
let mut o = String::from("(");
|
||||
for c in s.chars() {
|
||||
if matches!(c, '(' | ')' | '\\') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o.push(')');
|
||||
o
|
||||
}
|
||||
|
||||
/// Turn krilla's output into PDF/X-4. `None` when the file isn't the simple kind this
|
||||
/// understands (a classic cross-reference table and plain dictionaries).
|
||||
pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option<Vec<u8>> {
|
||||
let t_at = rfind(pdf, b"trailer")?;
|
||||
let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string();
|
||||
let size = int_after(&trailer, "/Size")?;
|
||||
let root = int_after(&trailer, "/Root")?;
|
||||
let prev = int_after(&trailer, "startxref")?;
|
||||
let info = int_after(&trailer, "/Info");
|
||||
let id = trailer.find("/ID").and_then(|i| trailer[i..].find(']').map(|j| trailer[i..i + j + 1].to_string()));
|
||||
let cat = String::from_utf8_lossy(&pdf[object(pdf, root)?]).to_string();
|
||||
let mut out = pdf.to_vec();
|
||||
if !out.ends_with(b"\n") {
|
||||
out.push(b'\n');
|
||||
}
|
||||
let mut entries: Vec<(usize, usize)> = Vec::new();
|
||||
let mut put = |out: &mut Vec<u8>, id: usize, body: &[u8]| {
|
||||
entries.push((id, out.len()));
|
||||
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
// The output profile and the output intent.
|
||||
let icc = designcraft_color::cms::icc_out::generic_cmyk_icc();
|
||||
let (icc_id, oi_id) = (size, size + 1);
|
||||
let mut s = format!("<</N 4/Length {}>>\nstream\n", icc.len()).into_bytes();
|
||||
s.extend_from_slice(icc);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
put(&mut out, icc_id, &s);
|
||||
let cond = designcraft_color::cms::GENERIC_CMYK;
|
||||
put(
|
||||
&mut out,
|
||||
oi_id,
|
||||
format!(
|
||||
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}/OutputCondition{}/Info{}/RegistryName(http://www.color.org)/DestOutputProfile {icc_id} 0 R>>",
|
||||
lit("Custom"),
|
||||
lit(cond),
|
||||
lit(cond)
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
// The catalog with the intent.
|
||||
let close = cat.rfind(">>")?;
|
||||
let cat_new = format!("{}/OutputIntents[{oi_id} 0 R]{}", cat[..close].trim(), &cat[close..]);
|
||||
put(&mut out, root, cat_new.trim().as_bytes());
|
||||
// Document information: the PDF/X version and trapping state.
|
||||
let mut next = size + 2;
|
||||
let info_id = match info {
|
||||
Some(i) => {
|
||||
let body = String::from_utf8_lossy(&pdf[object(pdf, i)?]).to_string();
|
||||
let close = body.rfind(">>")?;
|
||||
put(&mut out, i, format!("{}/GTS_PDFXVersion(PDF/X-4)/Trapped/False{}", body[..close].trim(), &body[close..]).trim().as_bytes());
|
||||
i
|
||||
}
|
||||
None => {
|
||||
let i = next;
|
||||
next += 1;
|
||||
put(&mut out, i, format!("<</Title{}/GTS_PDFXVersion(PDF/X-4)/Trapped/False>>", lit(title)).as_bytes());
|
||||
i
|
||||
}
|
||||
};
|
||||
// XMP: the PDF/X identification schema.
|
||||
if let Some(meta) = int_after(&cat, "/Metadata") {
|
||||
let r = object(pdf, meta)?;
|
||||
let body = &pdf[r];
|
||||
let s0 = find(body, b"stream", 0)? + b"stream".len();
|
||||
let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
|
||||
let s1 = rfind(body, b"endstream")?;
|
||||
let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string();
|
||||
let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string();
|
||||
if dict.contains("/Filter") {
|
||||
return None;
|
||||
}
|
||||
let add = "<rdf:Description rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"><pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion></rdf:Description>";
|
||||
let xml = xml.replacen("</rdf:RDF>", &format!("{add}</rdf:RDF>"), 1);
|
||||
let dict = match dict.find("/Length") {
|
||||
Some(i) => {
|
||||
let rest = &dict[i + 7..];
|
||||
let rest = rest.trim_start();
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
format!("{}/Length {}{}", &dict[..i], xml.len(), &rest[end..])
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
let mut b = dict.trim().as_bytes().to_vec();
|
||||
b.extend_from_slice(b"\nstream\n");
|
||||
b.extend_from_slice(xml.as_bytes());
|
||||
b.extend_from_slice(b"\nendstream");
|
||||
put(&mut out, meta, &b);
|
||||
}
|
||||
// Cross-reference section and trailer.
|
||||
let xref_at = out.len();
|
||||
entries.sort();
|
||||
let mut x = String::from("xref\n");
|
||||
for (oid, off) in &entries {
|
||||
x.push_str(&format!("{oid} 1\n{off:010} 00000 n\r\n"));
|
||||
}
|
||||
let id = id.unwrap_or_default();
|
||||
x.push_str(&format!("trailer\n<</Size {next}/Root {root} 0 R/Info {info_id} 0 R{id}/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n"));
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PDF/X-4 checks on an exported file: what's missing (empty = passes these checks).
|
||||
pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
let text = String::from_utf8_lossy(pdf);
|
||||
if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'6') {
|
||||
issues.push("the PDF version must be 1.6 or lower".into());
|
||||
}
|
||||
if !text.contains("/S/GTS_PDFX") {
|
||||
issues.push("no PDF/X output intent".into());
|
||||
}
|
||||
if !text.contains("/DestOutputProfile") {
|
||||
issues.push("the output intent has no destination profile".into());
|
||||
}
|
||||
if !text.contains("/GTS_PDFXVersion(PDF/X-4)") {
|
||||
issues.push("the document information doesn't name PDF/X-4".into());
|
||||
}
|
||||
if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") {
|
||||
issues.push("the XMP metadata doesn't name PDF/X-4".into());
|
||||
}
|
||||
if text.contains("/Encrypt") {
|
||||
issues.push("the file is encrypted".into());
|
||||
}
|
||||
let pages = text.matches("/Type/Page/").count() + text.matches("/Type/Page>").count();
|
||||
let trims = text.matches("/TrimBox").count();
|
||||
if pages > 0 && trims < pages {
|
||||
issues.push("every page needs a trim box".into());
|
||||
}
|
||||
if text.contains("/Subtype/Type3") {
|
||||
issues.push("Type 3 fonts aren't allowed".into());
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
|
@ -156,7 +156,15 @@ fn standards() {
|
|||
assert!(String::from_utf8_lossy(&r.bytes).contains("pdfaid"));
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap();
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.6"));
|
||||
assert!(!r.warnings.is_empty());
|
||||
// Output intent, identification and checks all in place; the update still reads.
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(crate::check_pdfx4(&r.bytes).is_empty());
|
||||
let text = String::from_utf8_lossy(&r.bytes).to_string();
|
||||
assert!(text.contains("/OutputIntents[") && text.contains("DesignCraft Generic CMYK"));
|
||||
// A plain export fails the checks; both read with the same pages.
|
||||
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
assert_eq!(hayro_syntax::Pdf::new(r.bytes.clone()).unwrap().pages().len(), hayro_syntax::Pdf::new(plain.clone()).unwrap().pages().len());
|
||||
assert!(crate::check_pdfx4(&plain).len() >= 3);
|
||||
assert_eq!(Standard::parse("PDF/X-4"), Some(Standard::PdfX4));
|
||||
assert_eq!(Standard::parse("pdfa-2b"), Some(Standard::PdfA2b));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,16 @@ fn trans_dict(t: &PageTransition) -> String {
|
|||
format!("/Trans<</Type/Trans{body}/D {:.2}>>", t.duration.clamp(0.0, 60.0))
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
|
||||
}
|
||||
|
||||
fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).rposition(|w| w == needle)
|
||||
}
|
||||
|
||||
/// The integer after `key` in `s` (e.g. `/Size 14`, `/Root 13 0 R`).
|
||||
fn int_after(s: &str, key: &str) -> Option<usize> {
|
||||
pub(crate) fn int_after(s: &str, key: &str) -> Option<usize> {
|
||||
let i = s.find(key)? + key.len();
|
||||
let rest = s[i..].trim_start();
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
|
|
@ -43,7 +43,7 @@ fn int_after(s: &str, key: &str) -> Option<usize> {
|
|||
}
|
||||
|
||||
/// Byte range of object `id`'s body (between `obj` and `endobj`).
|
||||
fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> {
|
||||
pub(crate) fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> {
|
||||
let head = format!("\n{id} 0 obj");
|
||||
let start = find(pdf, head.as_bytes(), 0)? + head.len();
|
||||
let end = find(pdf, b"endobj", start)?;
|
||||
|
|
|
|||
Loading…
Reference in New Issue