diff --git a/docs/decisions/report-engine.md b/docs/decisions/report-engine.md index e635f6e6..2f4e547b 100644 --- a/docs/decisions/report-engine.md +++ b/docs/decisions/report-engine.md @@ -39,9 +39,18 @@ license) and Droid Sans Fallback (CJK ideographs and kana; Apache-2.0). They live in `src-tauri/fonts/` and are compiled into the app, so output never depends on the fonts a machine has installed. Their advance widths ship as runs in `src/reports/engine/fallback-metrics.ts`, which a Rust test generates -from the font files and fails on when it is stale. Hebrew, Arabic and other -scripts that need shaping or right-to-left order are excluded. A character no -font covers still prints as `?`, in the preview and in the PDF alike. Bold text +from the font files and fails on when it is stale. + +The fallback fonts never draw these code points, which print as `?`: +WinAnsi characters (Helvetica draws them), U+0590–U+08FF (Hebrew, Arabic, +Syriac, Thaana, N'Ko, Samaritan, Mandaic and their supplements), U+FB1D–U+FDFF +and U+FE70–U+FEFF (Hebrew and Arabic presentation forms), and lone surrogates. +These scripts need right-to-left order or contextual shaping, which the +engine doesn't do. Neither font has Hangul syllables, Thai, or Devanagari and +the other Indic scripts, so they print as `?` too. Lao and combining marks +are in DejaVu Sans and print, but unshaped: a mark sits at the pen position +instead of over its base. A character no font covers prints as `?`, in the +preview and in the PDF alike. Bold text in a fallback font uses the regular glyphs at the same width; the PDF strokes their outlines to darken them. @@ -100,12 +109,23 @@ past its box. height of its wrapped text. Every component that starts at or below a growing box's designed bottom edge moves down by that box's growth plus the box's own shift, so stacked boxes push each other down. The band grows by the largest - shift plus growth, and pagination uses the grown height, so a grown band - moves to the next page like any other. A band never splits inside grown text. - Text is measured with the band's row scope before pagination, so `page` and - `pages` don't make a box grow. A repeated group header prints at its grown - height. In a band with a table, and in page headers and footers, boxes keep - their designed height; a table band reports a diagnostic. + shift plus growth, and pagination uses the grown height. Text is measured + with the band's row scope before pagination, so `page` and `pages` don't + make a box grow. A repeated group header prints at its grown height. In a + band with a table, and in page headers and footers, boxes keep their + designed height; a table band reports a diagnostic, and the designer hides + Can grow on page header and footer text. +- A band whose text grew splits across pages (`engine/split.ts`) unless + `keepTogether` is set and the band fits on one page. It starts on the + current page when its designed height fits there. Layout renders the whole + band at its own origin, then cuts it into pieces that fit the remaining + body: a cut falls between text lines, and an image or line that would cross + it moves the cut above that item. Text keeps the lines that start in a + piece; text frames, rectangles and vertical lines are clipped to it, so a + bordered box shows its own part on each page. Each continuation starts at + the body top below any repeated group headers, and components under the + grown text follow its last piece. When not even one line fits an empty page + body, the band is cut at the body bottom. ### Determinism guarantees and tolerances @@ -145,11 +165,18 @@ grayscale, RGB, and palette PNGs pass their IDAT data through with `FlateDecode` and the PNG predictor. Every other PNG (an alpha channel, `tRNS` transparency, interlacing, 16-bit samples) is decoded by Rust into 8-bit color samples and, when any pixel is translucent, an 8-bit alpha image that the -writer attaches as the image's `/SMask`. Other formats print as a crossed -placeholder box. +writer attaches as the image's `/SMask`. 16-bit samples never pass through, +because `/BitsPerComponent 16` needs PDF 1.5. Rust rejects a PNG that +declares more than 50 megapixels before it allocates the pixel buffer, and +caps the decoder's own buffers at 64 MiB, so a small file that declares a huge +image can't exhaust memory. Other formats, and PNGs that are too large or +unreadable, print as a crossed placeholder box, and the export message names +each such image and the reason. Export calls `prepare_report_pdf` (`src-tauri/src/report_pdf.rs`) only when a -report needs a fallback font or a decoded PNG. It returns the font subsets +report needs a fallback font or a decoded PNG. It is an async command, so the +subsetting, decoding and deflating run on a worker thread instead of the main +thread. It returns the font subsets (`subsetter` crate, glyph ids renumbered in ascending order, subset tag hashed from the glyphs) and the decoded images, all zlib-compressed by the pinned `flate2` backend. Its output depends only on its input, so the same report @@ -194,6 +221,7 @@ arbitrary HTML or CSS. This engine also leaves these for later: right-to-left layout - can-grow text in bands with a table, can-shrink, and more than one table per band +- caching font subsets between exports - image formats other than JPEG and PNG, which print as placeholders in the PDF - color - content stream compression and PDF/A @@ -209,10 +237,14 @@ arbitrary HTML or CSS. This engine also leaves these for later: - `tests/unit/report-grow.test.ts`: can-grow boxes push components and the band down, chain through stacked boxes, paginate by grown height, and repeat at grown height. +- `tests/unit/report-grow-split.test.ts`: a grown band taller than a page splits + across pages without losing or repeating a line, a sibling beside it stays + put, `keepTogether` moves a band that fits, and the cut and slice helpers. - `tests/unit/report-unicode.test.ts`: fallback metrics, font runs, Type0 font objects and CID text, and SMask image objects. - `src-tauri/src/report_pdf_tests.rs`: glyph coverage, deterministic subsets, - the metrics table against the font files, and PNG alpha splitting. + the metrics table against the font files, PNG alpha splitting, and PNGs with + huge declared sizes, truncated data, 16-bit samples, and interlacing. - `tests/integration/report-pdf-resources.test.tsx`: exports Greek, Cyrillic, and CJK text and an RGBA PNG through the Rust command. - `tests/integration/report-parameters.test.tsx`: the parameter prompt from diff --git a/src-tauri/src/report_pdf.rs b/src-tauri/src/report_pdf.rs index 21b6197b..e4d8836f 100644 --- a/src-tauri/src/report_pdf.rs +++ b/src-tauri/src/report_pdf.rs @@ -124,6 +124,8 @@ pub struct ReportPdfResources { pub fonts: Vec, /// One entry per input PNG, in order; null when it can't be decoded. pub images: Vec>, + /// Why images print as placeholders (one line per undecodable PNG, by input position). + pub warnings: Vec, } fn deflate(data: &[u8]) -> Result, AppError> { @@ -200,20 +202,46 @@ pub fn font_subsets(code_points: &[u32]) -> Result, AppError> Ok(out) } +/// Largest PNG the writer decodes. A header can declare any size, so the +/// output buffer is never allocated before this check. +pub const MAX_PNG_PIXELS: u64 = 50_000_000; +/// Allocation budget for the PNG decoder's own buffers. +const PNG_DECODER_BYTES: usize = 64 << 20; + /// Decodes any PNG (palette, low bit depth, 16-bit, interlaced, tRNS) to /// 8-bit gray or RGB samples plus an alpha mask when any pixel is translucent. -pub fn decode_png(bytes: &[u8]) -> Option { - let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); +/// Errors say why the image prints as a placeholder instead. +pub fn decode_png(bytes: &[u8]) -> Result { + let mut decoder = png::Decoder::new_with_limits( + std::io::Cursor::new(bytes), + png::Limits { + bytes: PNG_DECODER_BYTES, + }, + ); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); - let mut reader = decoder.read_info().ok()?; - let mut buf = vec![0; reader.output_buffer_size()?]; - let info = reader.next_frame(&mut buf).ok()?; + let mut reader = decoder + .read_info() + .map_err(|e| format!("unreadable PNG: {e}"))?; + let (width, height) = (reader.info().width, reader.info().height); + if u64::from(width) * u64::from(height) > MAX_PNG_PIXELS { + return Err(format!( + "PNG is {width}×{height}, larger than the {} megapixel limit", + MAX_PNG_PIXELS / 1_000_000 + )); + } + let size = reader + .output_buffer_size() + .ok_or_else(|| "PNG is too large to decode".to_string())?; + let mut buf = vec![0; size]; + let info = reader + .next_frame(&mut buf) + .map_err(|e| format!("unreadable PNG: {e}"))?; let (colors, has_alpha) = match info.color_type { png::ColorType::Grayscale => (1, false), png::ColorType::GrayscaleAlpha => (1, true), png::ColorType::Rgb => (3, false), png::ColorType::Rgba => (3, true), - png::ColorType::Indexed => return None, + png::ColorType::Indexed => return Err("unsupported PNG color type".into()), }; let channels = colors + usize::from(has_alpha); let pixels = info.width as usize * info.height as usize; @@ -230,42 +258,58 @@ pub fn decode_png(bytes: &[u8]) -> Option { } else { (samples.to_vec(), None) }; - Some(PdfPngImage { + let compress = |data: &[u8]| deflate(data).map(|d| b64(&d)).map_err(|e| e.message); + Ok(PdfPngImage { width: info.width, height: info.height, colors: colors as u8, - color_base64: b64(&deflate(&color).ok()?), - alpha_base64: match alpha { - Some(a) => Some(b64(&deflate(&a).ok()?)), - None => None, - }, + color_base64: compress(&color)?, + alpha_base64: alpha.as_deref().map(compress).transpose()?, }) } -/// Font subsets for the characters Helvetica can't print and decoded PNGs -/// (base64) that the writer can't pass through (alpha, tRNS, interlacing). -#[tauri::command] -pub fn prepare_report_pdf( - window_label: String, - code_points: Vec, - pngs_base64: Vec, +/// Font subsets and decoded PNGs for one export (see `prepare_report_pdf`). +pub fn pdf_resources( + code_points: &[u32], + pngs_base64: &[String], ) -> Result { - crate::manager()?.state(&window_label)?; + let mut warnings = vec![]; let images = pngs_base64 .iter() - .map(|data| { - base64::engine::general_purpose::STANDARD + .enumerate() + .map(|(i, data)| { + let decoded = base64::engine::general_purpose::STANDARD .decode(data.as_bytes()) + .map_err(|e| format!("invalid image data: {e}")) + .and_then(|bytes| decode_png(&bytes)); + decoded + .map_err(|reason| { + crate::logging::warn("reports", &format!("PDF image {}: {reason}", i + 1)); + warnings.push(format!("Image {}: {reason}", i + 1)); + }) .ok() - .and_then(|bytes| decode_png(&bytes)) }) .collect(); Ok(ReportPdfResources { - fonts: font_subsets(&code_points)?, + fonts: font_subsets(code_points)?, images, + warnings, }) } +/// Font subsets for the characters Helvetica can't print and decoded PNGs +/// (base64) that the writer can't pass through (alpha, tRNS, interlacing, +/// 16-bit). Runs on a worker thread: subsetting and deflating take a while. +#[tauri::command(async)] +pub fn prepare_report_pdf( + window_label: String, + code_points: Vec, + pngs_base64: Vec, +) -> Result { + crate::manager()?.state(&window_label)?; + pdf_resources(&code_points, &pngs_base64) +} + #[cfg(test)] mod tests { include!("report_pdf_tests.rs"); diff --git a/src-tauri/src/report_pdf_tests.rs b/src-tauri/src/report_pdf_tests.rs index 837aeb9a..9aba4985 100644 --- a/src-tauri/src/report_pdf_tests.rs +++ b/src-tauri/src/report_pdf_tests.rs @@ -193,7 +193,7 @@ fn rgba_png_splits_into_color_and_soft_mask() { inflate(img.alpha_base64.as_deref().unwrap()), vec![255, 128, 0, 255] ); - assert_eq!(decode_png(&bytes), Some(img), "deterministic"); + assert_eq!(decode_png(&bytes), Ok(img), "deterministic"); } #[test] @@ -243,5 +243,86 @@ fn palette_png_with_transparency_expands_to_rgb_and_mask() { assert_eq!(img.colors, 3); assert_eq!(inflate(&img.color_base64), vec![10, 20, 30, 40, 50, 60]); assert_eq!(inflate(img.alpha_base64.as_deref().unwrap()), vec![0, 255]); - assert_eq!(decode_png(b"not a png"), None); + assert!(decode_png(b"not a png") + .unwrap_err() + .starts_with("unreadable PNG")); +} + +/// A PNG written chunk by chunk, for headers the encoder won't produce. +fn raw_png(ihdr: [u8; 13], scanlines: &[u8]) -> Vec { + let chunk = |out: &mut Vec, kind: &[u8; 4], data: &[u8]| { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(kind); + out.extend_from_slice(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + }; + let mut out = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &deflate(scanlines).unwrap()); + chunk(&mut out, b"IEND", &[]); + out +} + +fn ihdr(width: u32, height: u32, depth: u8, color: u8, interlace: u8) -> [u8; 13] { + let mut h = [0; 13]; + h[..4].copy_from_slice(&width.to_be_bytes()); + h[4..8].copy_from_slice(&height.to_be_bytes()); + h[8] = depth; + h[9] = color; + h[12] = interlace; + h +} + +#[test] +fn huge_declared_png_is_rejected_before_allocating() { + // 40000×40000 RGBA would need 6.4 GB; the file itself is tiny. + let bytes = raw_png(ihdr(40_000, 40_000, 8, 6, 0), &[0]); + assert!(bytes.len() < 100); + let err = decode_png(&bytes).unwrap_err(); + assert!(err.contains("40000×40000"), "{err}"); + assert!(err.contains("50 megapixel"), "{err}"); + let resources = pdf_resources(&[], &[b64(&bytes)]).unwrap(); + assert_eq!(resources.images, vec![None]); + assert_eq!(resources.warnings.len(), 1); + assert!(resources.warnings[0].starts_with("Image 1: PNG is 40000×40000")); +} + +#[test] +fn truncated_png_is_rejected() { + let pixels = [7u8; 4 * 4 * 3]; + let bytes = encode_png(png::ColorType::Rgb, png::BitDepth::Eight, 4, 4, &pixels); + let err = decode_png(&bytes[..bytes.len() / 2]).unwrap_err(); + assert!(err.starts_with("unreadable PNG"), "{err}"); + let resources = pdf_resources(&[], &["@@not base64".into()]).unwrap(); + assert_eq!(resources.images, vec![None]); + assert!(resources.warnings[0].starts_with("Image 1: invalid image data")); +} + +#[test] +fn sixteen_bit_rgb_png_is_stripped_to_eight_bits() { + let pixels = [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc]; + let bytes = encode_png(png::ColorType::Rgb, png::BitDepth::Sixteen, 1, 1, &pixels); + let img = decode_png(&bytes).unwrap(); + assert_eq!((img.colors, img.alpha_base64.clone()), (3, None)); + assert_eq!(inflate(&img.color_base64), vec![0x12, 0x56, 0x9a]); +} + +#[test] +fn interlaced_png_is_deinterlaced() { + // Adam7 on 2×2 RGB: pass 1 holds (0,0), pass 6 holds (1,0), pass 7 holds row 1. + let scanlines = [ + 0, 1, 2, 3, // pass 1 + 0, 4, 5, 6, // pass 6 + 0, 7, 8, 9, 10, 11, 12, // pass 7 + ]; + let bytes = raw_png(ihdr(2, 2, 8, 2, 1), &scanlines); + let img = decode_png(&bytes).unwrap(); + assert_eq!((img.width, img.height, img.colors), (2, 2, 3)); + assert_eq!( + inflate(&img.color_base64), + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + ); } diff --git a/src/reports/ReportPreview.tsx b/src/reports/ReportPreview.tsx index 2780e88e..d7fc5a4e 100644 --- a/src/reports/ReportPreview.tsx +++ b/src/reports/ReportPreview.tsx @@ -147,12 +147,15 @@ function LoadedPreview({ try { const path = await choosePdfDestination(report.name); if (!path) return; - const bytes = await reportPdfBytes(result.doc, result.data.assets, { + const { bytes, warnings } = await reportPdfBytes(result.doc, result.data.assets, { title: report.name, creationDate: result.generatedAt, }); await writeReportPdf(path, bytes, report.id); - setNotice(`Exported PDF to ${path}`); + const placeholders = warnings.length + ? `. Printed as placeholders: ${warnings.join("; ")}` + : ""; + setNotice(`Exported PDF to ${path}${placeholders}`); } catch (reason) { setError(asTauriError(reason).message); } diff --git a/src/reports/ReportRun.tsx b/src/reports/ReportRun.tsx index 9dcce91d..ad53df67 100644 --- a/src/reports/ReportRun.tsx +++ b/src/reports/ReportRun.tsx @@ -107,7 +107,10 @@ export function ReportRun({ const report = config.reports.find((r) => r.id === reportId); const parameters = report ? reportParameters(report, config.savedQueries) : []; const given = params ?? {}; - const complete = parameters.every((p) => p.name in given); + // A required parameter passed as null still needs a value, so it prompts too. + const complete = parameters.every( + (p) => p.name in given && (!p.required || (given[p.name] ?? null) !== null), + ); const [values, setValues] = useState | null>(complete ? given : null); const [prompting, setPrompting] = useState(!complete); if (!report) return ; diff --git a/src/reports/api.ts b/src/reports/api.ts index 8f4d3390..b0d6d692 100644 --- a/src/reports/api.ts +++ b/src/reports/api.ts @@ -30,6 +30,8 @@ export interface PdfResources { fonts: PdfFontSubset[]; /** One per requested PNG, in order; null when it can't be decoded. */ images: (DecodedPng | null)[]; + /** Why images print as placeholders, one line per undecodable PNG. */ + warnings: string[]; } /** Font subsets and decoded PNGs for the PDF writer (Rust `prepare_report_pdf`). */ diff --git a/src/reports/designer/ComponentProperties.tsx b/src/reports/designer/ComponentProperties.tsx index 2d32103f..40e10f31 100644 --- a/src/reports/designer/ComponentProperties.tsx +++ b/src/reports/designer/ComponentProperties.tsx @@ -15,6 +15,8 @@ const FILLS: [string, number | null][] = [ interface Props { component: ReportComponent; + /** Page header or footer: their height is fixed, so text can't grow there. */ + pageBand?: boolean; columns: string[]; queries: SavedQuery[]; assets: AssetSummary[]; @@ -25,6 +27,7 @@ interface Props { /** Property editor for the selected report component. */ export function ComponentProperties({ component: c, + pageBand = false, columns, queries, assets, @@ -167,7 +170,7 @@ export function ComponentProperties({ Bold )} - {isText && ( + {isText && !pageBand && (