mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
M10: exported TIFF and JPEG pages record the resolution they were drawn at (#3332)
* M10: exported TIFF and JPEG pages record the resolution they were drawn at A page exported as TIFF said 1 x 1 with no unit, whatever the dpi, and a JPEG export said nothing, so other programs showed and printed them at a default (a 300-dpi page shown at 72 dpi is four times too big). encode_image now takes the dpi: TIFF X/YResolution in inches, the JFIF density in dots per inch. PNG stays without pHYs. It counts pixels per metre, so 72 dpi would read back as 72.009, and a page made from an exported PNG (doc_create from images, as enhance_camera_image_through_tools does) would come out 0.01% small. Exporter records the resolution a page was actually drawn at (dpi_used), so a page too large for the renderer says the lower one. render records --dpi, since it refuses oversized pages. The TIFF is written with the tiff crate, already in the graph, because image's TiffEncoder can't set a resolution. * M10: TIFF page exports are LZW-compressed, as documented encode_tiff's comment said "LZW-compressed by the encoder's default", but the TIFF was written uncompressed: a Letter page at 300 dpi came to 25 MB. It is now LZW without a predictor, the compression TIFF readers support most widely after none. * Contributors: add my name for the About window
This commit is contained in:
Generated
+1
@@ -4136,6 +4136,7 @@ dependencies = [
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -622,7 +622,8 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
return Err(e.into());
|
||||
}
|
||||
let bytes = match format {
|
||||
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
|
||||
// With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
|
||||
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
|
||||
None => {
|
||||
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
|
||||
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
|
||||
|
||||
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
|
||||
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_records_the_dpi_it_was_rendered_at() {
|
||||
// The engine's tests check each format; this checks that `--dpi` reaches the file.
|
||||
let pdf = fixture("dpi");
|
||||
let jpg = tmp("dpi", "p1.jpg");
|
||||
assert!(render(&pdf, &jpg).status.success());
|
||||
let bytes = std::fs::read(&jpg).unwrap();
|
||||
assert_eq!(&bytes[6..11], b"JFIF\0");
|
||||
assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pam_name_still_gets_the_netpbm_stream() {
|
||||
let pdf = fixture("pam");
|
||||
|
||||
@@ -145,3 +145,6 @@ sync_github_name = false
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -37,6 +37,9 @@ pdfcraft-model = { workspace = true }
|
||||
pdfcraft-geom = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
png = "0.18"
|
||||
# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the
|
||||
# resolution or the compression of an exported page.
|
||||
tiff = "0.11"
|
||||
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
|
||||
# the host of a document's link the way the browser reads it (links::display_host).
|
||||
url = "2.5"
|
||||
|
||||
+31
-15
@@ -93,14 +93,14 @@ impl Exporter {
|
||||
encode_png(r.width, r.height, &r.rgba)
|
||||
}
|
||||
|
||||
/// Page `page` as an image file of `format`.
|
||||
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
|
||||
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format)
|
||||
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
|
||||
}
|
||||
|
||||
/// The reading-order text of a page.
|
||||
@@ -127,12 +127,14 @@ impl Exporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → an image file of `format`.
|
||||
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch,
|
||||
/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts
|
||||
/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink.
|
||||
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
match format {
|
||||
ImageFormat::Png => encode_png(width, height, premultiplied),
|
||||
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
|
||||
ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
|
||||
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi),
|
||||
ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper).
|
||||
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result<Vec<u8>, String> {
|
||||
/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper).
|
||||
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
|
||||
let rgb = over_white(premultiplied);
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
|
||||
.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
|
||||
// The JFIF density is whole dots per inch.
|
||||
enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16));
|
||||
enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
|
||||
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly:
|
||||
/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed.
|
||||
/// No predictor: LZW alone is the compression TIFF readers support most widely after none.
|
||||
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result<Vec<u8>, String> {
|
||||
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
|
||||
use tiff::tags::{ResolutionUnit, Tag};
|
||||
let err = |e: tiff::TiffError| e.to_string();
|
||||
let rgb = over_white(premultiplied);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
|
||||
let mut image = tiff.new_image::<RGB8>(width, height).map_err(err)?;
|
||||
// In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number.
|
||||
let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 };
|
||||
let tags = image.encoder();
|
||||
tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?;
|
||||
tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?;
|
||||
tags.write_tag(Tag::YResolution, resolution).map_err(err)?;
|
||||
image.write_data(&rgb).map_err(err)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
|
||||
@@ -2574,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() {
|
||||
let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap();
|
||||
let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (400, 600));
|
||||
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap();
|
||||
let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap();
|
||||
let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap();
|
||||
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
|
||||
}
|
||||
|
||||
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
|
||||
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
|
||||
use tiff::{decoder::ifd::Value, tags::Tag};
|
||||
let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut res = |tag| match d.get_tag(tag).unwrap() {
|
||||
Value::Rational(n, d) => f64::from(n) / f64::from(d),
|
||||
v => panic!("{tag:?} is {v:?}"),
|
||||
};
|
||||
let (x, y) = (res(Tag::XResolution), res(Tag::YResolution));
|
||||
(d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y)
|
||||
}
|
||||
|
||||
/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it
|
||||
/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing.
|
||||
#[test]
|
||||
fn exported_pages_record_the_resolution_they_were_drawn_at() {
|
||||
use crate::export::{Exporter, ImageFormat};
|
||||
let (s, id) = session_with(1);
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0));
|
||||
let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
|
||||
assert_eq!(&jpg[6..11], b"JFIF\0");
|
||||
assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch");
|
||||
// pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG
|
||||
// (doc_create from images) would come out 0.01% small.
|
||||
let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
|
||||
assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution");
|
||||
|
||||
// A page too large for the renderer at the asked resolution says the one it was drawn at.
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
|
||||
let id = s.open("wide.pdf", None, bytes, None).unwrap();
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
let used = ex.dpi_used(0, 600.0);
|
||||
let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap());
|
||||
assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user