Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions docs/decisions/report-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
92 changes: 68 additions & 24 deletions src-tauri/src/report_pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ pub struct ReportPdfResources {
pub fonts: Vec<PdfFontSubset>,
/// One entry per input PNG, in order; null when it can't be decoded.
pub images: Vec<Option<PdfPngImage>>,
/// Why images print as placeholders (one line per undecodable PNG, by input position).
pub warnings: Vec<String>,
}

fn deflate(data: &[u8]) -> Result<Vec<u8>, AppError> {
Expand Down Expand Up @@ -200,20 +202,46 @@ pub fn font_subsets(code_points: &[u32]) -> Result<Vec<PdfFontSubset>, 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<PdfPngImage> {
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<PdfPngImage, String> {
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;
Expand All @@ -230,42 +258,58 @@ pub fn decode_png(bytes: &[u8]) -> Option<PdfPngImage> {
} 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<u32>,
pngs_base64: Vec<String>,
/// Font subsets and decoded PNGs for one export (see `prepare_report_pdf`).
pub fn pdf_resources(
code_points: &[u32],
pngs_base64: &[String],
) -> Result<ReportPdfResources, AppError> {
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<u32>,
pngs_base64: Vec<String>,
) -> Result<ReportPdfResources, AppError> {
crate::manager()?.state(&window_label)?;
pdf_resources(&code_points, &pngs_base64)
}

#[cfg(test)]
mod tests {
include!("report_pdf_tests.rs");
Expand Down
85 changes: 83 additions & 2 deletions src-tauri/src/report_pdf_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<u8> {
let chunk = |out: &mut Vec<u8>, 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]
);
}
7 changes: 5 additions & 2 deletions src/reports/ReportPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 4 additions & 1 deletion src/reports/ReportRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown> | null>(complete ? given : null);
const [prompting, setPrompting] = useState(!complete);
if (!report) return <ReportPreview reportId={reportId} params={params} />;
Expand Down
2 changes: 2 additions & 0 deletions src/reports/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down
5 changes: 4 additions & 1 deletion src/reports/designer/ComponentProperties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -25,6 +27,7 @@ interface Props {
/** Property editor for the selected report component. */
export function ComponentProperties({
component: c,
pageBand = false,
columns,
queries,
assets,
Expand Down Expand Up @@ -167,7 +170,7 @@ export function ComponentProperties({
Bold
</label>
)}
{isText && (
{isText && !pageBand && (
<label className="inline">
<input
type="checkbox"
Expand Down
Loading
Loading