mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
fix(engine): PNG export writes opaque pages as RGB at deflate level 3 (3–5× faster, same size) (#2425)
* fix(engine): PNG export writes opaque pages as RGB at deflate level 3 `encode_png` wrote every raster as RGBA at the png crate's default level (zlib level 6). A rendered page is always opaque, so the alpha channel only made the file bigger and slower to write. An opaque raster is now written as RGB, at deflate level 3 with adaptive filtering; a raster with translucent pixels is still written as RGBA with straight alpha. The files come out the same size, 3-5x faster (release build, Linux): 300-dpi scan drawn at 220 dpi 805 -> 167 ms 4.46 -> 4.47 MB text page at 220 dpi 70 -> 40 ms 0.85 -> 0.89 MB annual-report page at 150 dpi 179 -> 64 ms 1.41 -> 1.38 MB This is the encoder behind Export > Image (PNG), `pdfcraft-cli render` and Print as Image; the automation tools (`page_render` and the other page previews, MCP included) had a copy of it and now call it instead, so png is only a dev-dependency of pdfcraft-automation. Two automation tests that read `page_render` output as RGBA now accept the RGB a page renders as. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ * fix(engine): PNG export rejects a raster of the wrong length encode_png now checks that the buffer is exactly width × height × 4 bytes (checked arithmetic) before choosing RGB or RGBA. The opaque path's as_chunks::<4>() dropped a partial trailing pixel, so encode_png(1, 1, &[10, 20, 30, 255, 0]) wrote a valid 1×1 PNG where the old RGBA encoder returned an error. Regression test export::png_tests::rasters_of_the_wrong_length_are_rejected covers opaque and translucent buffers with a partial pixel, a pixel short, a pixel too many, an empty buffer and a size whose byte count overflows; it fails on the previous head at the reviewer's case. Review: https://github.com/storytold/pdfcraft/pull/2425#discussion_r4281603405 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
322a1b2ac0
commit
8f3456f29c
@@ -218,6 +218,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
|
||||
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
|
||||
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
|
||||
- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
|
||||
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
|
||||
|
||||
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
|
||||
|
||||
@@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
png = "0.18"
|
||||
base64 = { version = "0.22", optional = true }
|
||||
|
||||
|
||||
@@ -28,6 +27,7 @@ mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
png = "0.18"
|
||||
flate2.workspace = true
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
|
||||
@@ -2506,24 +2506,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
|
||||
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
|
||||
}
|
||||
|
||||
/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same).
|
||||
fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut rgba = premultiplied.to_vec();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let a = u32::from(px[3]);
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, width, height);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut w = enc.write_header().map_err(failed)?;
|
||||
w.write_image_data(&rgba).map_err(failed)?;
|
||||
w.finish().map_err(failed)?;
|
||||
Ok(out)
|
||||
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
|
||||
}
|
||||
|
||||
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
|
||||
|
||||
@@ -3880,15 +3880,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut pixels).unwrap();
|
||||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([
|
||||
[240, 20, 20, 255],
|
||||
[20, 180, 20, 255],
|
||||
[20, 20, 240, 255],
|
||||
[230, 180, 20, 255],
|
||||
]) {
|
||||
let offset = ((y * info.width + x) * 4) as usize;
|
||||
assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}");
|
||||
// A rendered page is opaque, so it comes as RGB.
|
||||
assert_eq!(info.color_type, png::ColorType::Rgb);
|
||||
for ((x, y), colour) in
|
||||
[(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]])
|
||||
{
|
||||
let offset = ((y * info.width + x) * 3) as usize;
|
||||
assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6515,7 +6513,8 @@ fn reading_colors_workdir() -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels.
|
||||
/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages
|
||||
/// come as RGB; their alpha is 255.
|
||||
fn png_pixels(content: &Content) -> Vec<u8> {
|
||||
let Content::Png { data, width, height } = content else { panic!("expected a PNG") };
|
||||
assert_eq!((*width, *height), (200, 300));
|
||||
@@ -6523,7 +6522,11 @@ fn png_pixels(content: &Content) -> Vec<u8> {
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut pixels).unwrap();
|
||||
assert_eq!((info.width, info.height), (200, 300));
|
||||
pixels
|
||||
match info.color_type {
|
||||
png::ColorType::Rgba => pixels,
|
||||
png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
|
||||
other => panic!("unexpected colour type {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image.
|
||||
|
||||
+86
-10
@@ -138,23 +138,48 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → PNG (straight alpha).
|
||||
/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered
|
||||
/// page always is one: it is drawn on white) is written as RGB.
|
||||
///
|
||||
/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the
|
||||
/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at
|
||||
/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms
|
||||
/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual
|
||||
/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB).
|
||||
pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut rgba = premultiplied.to_vec();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let a = u32::from(px[3]);
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
// Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel,
|
||||
// and the opaque path would write the rest as a valid image.
|
||||
let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4));
|
||||
if expected != Some(premultiplied.len()) {
|
||||
return Err(match expected {
|
||||
Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()),
|
||||
None => format!("a {width}×{height} raster is too large"),
|
||||
});
|
||||
}
|
||||
let pixels = premultiplied.as_chunks::<4>().0;
|
||||
let opaque = pixels.iter().all(|px| px[3] == 255);
|
||||
let (color, data) = if opaque {
|
||||
(png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::<Vec<u8>>())
|
||||
} else {
|
||||
let mut rgba = premultiplied.to_vec();
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let a = u32::from(px[3]);
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(png::ColorType::Rgba, rgba)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, width, height);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_color(color);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
enc.set_deflate_compression(png::DeflateCompression::Level(3));
|
||||
enc.set_filter(png::Filter::Adaptive);
|
||||
let mut w = enc.write_header().map_err(|e| e.to_string())?;
|
||||
w.write_image_data(&rgba).map_err(|e| e.to_string())?;
|
||||
w.write_image_data(&data).map_err(|e| e.to_string())?;
|
||||
w.finish().map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -260,3 +285,54 @@ mod image_name_tests {
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod png_tests {
|
||||
use super::encode_png;
|
||||
|
||||
/// The colour type and pixels of a PNG.
|
||||
fn decode(png: &[u8]) -> (png::ColorType, Vec<u8>) {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut pixels).unwrap();
|
||||
pixels.truncate(frame.buffer_size());
|
||||
(frame.color_type, pixels)
|
||||
}
|
||||
|
||||
/// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as
|
||||
/// rendered (the alpha channel would only make the file bigger and slower to write).
|
||||
#[test]
|
||||
fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() {
|
||||
let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]];
|
||||
let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap());
|
||||
assert_eq!(color, png::ColorType::Rgb);
|
||||
assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied.
|
||||
#[test]
|
||||
fn translucent_rasters_keep_straight_alpha() {
|
||||
let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]];
|
||||
let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap());
|
||||
assert_eq!(color, png::ColorType::Rgba);
|
||||
assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]);
|
||||
}
|
||||
|
||||
/// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The
|
||||
/// opaque path used to drop a partial trailing pixel and write the rest as a valid image.
|
||||
#[test]
|
||||
fn rasters_of_the_wrong_length_are_rejected() {
|
||||
let cases: [(&str, u32, u32, &[u8]); 7] = [
|
||||
("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]),
|
||||
("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]),
|
||||
("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]),
|
||||
("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]),
|
||||
("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]),
|
||||
("empty", 1, 1, &[]),
|
||||
("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]),
|
||||
];
|
||||
for (what, width, height, data) in cases {
|
||||
assert!(encode_png(width, height, data).is_err(), "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -222,6 +223,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged |
|
||||
| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user