From d684382a72772e341f3cf06ff8ca9f1946517831 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 01:00:36 +0800 Subject: [PATCH 1/4] fix(rust): improve XLSX rendering parity Render legacy VML/EMF previews, stacked text, centered sheets, custom dates, and merged-cell continuations more faithfully; add KaiTi fallback support and prepare Rust crates 0.6.0. --- minipdf-rs/Cargo.lock | 5 +- minipdf-rs/Cargo.toml | 3 +- minipdf-rs/crates/minipdf-cli/Cargo.toml | 2 +- minipdf-rs/crates/minipdf-cli/src/main.rs | 5 +- minipdf-rs/crates/minipdf/Cargo.toml | 5 +- minipdf-rs/crates/minipdf/src/pdf.rs | 32 + minipdf-rs/crates/minipdf/src/xlsx.rs | 696 ++++++++++++++++++++-- 7 files changed, 702 insertions(+), 46 deletions(-) diff --git a/minipdf-rs/Cargo.lock b/minipdf-rs/Cargo.lock index 0b2042a2..5ec43419 100644 --- a/minipdf-rs/Cargo.lock +++ b/minipdf-rs/Cargo.lock @@ -437,7 +437,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "minipdf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "chrono", "flate2", @@ -448,12 +448,13 @@ dependencies = [ "thiserror", "ttf-parser", "unicode-bidi", + "windows-sys", "zip", ] [[package]] name = "minipdf-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "clap", "minipdf", diff --git a/minipdf-rs/Cargo.toml b/minipdf-rs/Cargo.toml index 9f7de7a2..3181aa66 100644 --- a/minipdf-rs/Cargo.toml +++ b/minipdf-rs/Cargo.toml @@ -6,7 +6,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2021" license = "Apache-2.0" repository = "https://github.com/mini-software/MiniPdf" @@ -25,4 +25,5 @@ subsetter = "=0.2.3" ttf-parser = "0.25" thiserror = "2" unicode-bidi = "0.3" +windows-sys = "0.61" zip = { version = "2.2", default-features = false, features = ["deflate"] } \ No newline at end of file diff --git a/minipdf-rs/crates/minipdf-cli/Cargo.toml b/minipdf-rs/crates/minipdf-cli/Cargo.toml index 2c5874a0..4186a2c6 100644 --- a/minipdf-rs/crates/minipdf-cli/Cargo.toml +++ b/minipdf-rs/crates/minipdf-cli/Cargo.toml @@ -18,5 +18,5 @@ path = "src/main.rs" [dependencies] clap.workspace = true -minipdf = { version = "0.5.0", path = "../minipdf" } +minipdf = { version = "0.6.0", path = "../minipdf" } ttf-parser.workspace = true \ No newline at end of file diff --git a/minipdf-rs/crates/minipdf-cli/src/main.rs b/minipdf-rs/crates/minipdf-cli/src/main.rs index 4a8a2976..43b502c0 100644 --- a/minipdf-rs/crates/minipdf-cli/src/main.rs +++ b/minipdf-rs/crates/minipdf-cli/src/main.rs @@ -314,6 +314,7 @@ fn system_fallback_font_paths() -> Vec { "ebrimabd.ttf", "YuGothR.ttc", "NotoSansSC-VF.ttf", + "simkai.ttf", "simsun.ttc", "simhei.ttf", "malgunsl.ttf", @@ -397,6 +398,8 @@ mod tests { assert!(names.iter().any(|candidate| candidate == name)); } - assert!(names.iter().any(|candidate| candidate == "simsun.ttc")); + for name in ["simkai.ttf", "simsun.ttc"] { + assert!(names.iter().any(|candidate| candidate == name)); + } } } diff --git a/minipdf-rs/crates/minipdf/Cargo.toml b/minipdf-rs/crates/minipdf/Cargo.toml index e15992ae..cc827453 100644 --- a/minipdf-rs/crates/minipdf/Cargo.toml +++ b/minipdf-rs/crates/minipdf/Cargo.toml @@ -23,4 +23,7 @@ subsetter.workspace = true ttf-parser.workspace = true thiserror.workspace = true unicode-bidi.workspace = true -zip.workspace = true \ No newline at end of file +zip.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_Graphics_Gdi"] } \ No newline at end of file diff --git a/minipdf-rs/crates/minipdf/src/pdf.rs b/minipdf-rs/crates/minipdf/src/pdf.rs index ef53ae4d..fe0ee1e1 100644 --- a/minipdf-rs/crates/minipdf/src/pdf.rs +++ b/minipdf-rs/crates/minipdf/src/pdf.rs @@ -289,6 +289,38 @@ impl PdfPage { pub fn pop_clip(&mut self) { self.ops.push(PdfOp::PopClip); } + + pub(crate) fn translate_y(&mut self, offset: f32) { + for operation in &mut self.ops { + match operation { + PdfOp::Text { y, .. } + | PdfOp::Rect { y, .. } + | PdfOp::Ellipse { y, .. } + | PdfOp::Image { y, .. } + | PdfOp::PushClip { y, .. } => *y += offset, + PdfOp::Line { y1, y2, .. } => { + *y1 += offset; + *y2 += offset; + } + PdfOp::Path { commands, .. } => { + for command in commands { + match command { + PdfPathCommand::MoveTo(_, y) | PdfPathCommand::LineTo(_, y) => { + *y += offset; + } + PdfPathCommand::CurveTo(_, y1, _, y2, _, y3) => { + *y1 += offset; + *y2 += offset; + *y3 += offset; + } + PdfPathCommand::Close => {} + } + } + } + PdfOp::PopClip => {} + } + } + } } #[derive(Debug, Clone)] diff --git a/minipdf-rs/crates/minipdf/src/xlsx.rs b/minipdf-rs/crates/minipdf/src/xlsx.rs index c36be15d..75400318 100644 --- a/minipdf-rs/crates/minipdf/src/xlsx.rs +++ b/minipdf-rs/crates/minipdf/src/xlsx.rs @@ -29,6 +29,7 @@ const O365_PRINTER_FALLBACK_FONT_SCALE: f32 = 0.96; const O365_PRINTER_FALLBACK_HORIZONTAL_SCALE: f32 = 1.0046; const O365_PRINTER_FALLBACK_LEFT_OFFSET: f32 = 0.48; const O365_PRINTER_FALLBACK_BORDER_SCALE: f32 = 1.8; +const CENTERED_VML_HORIZONTAL_SCALE: f32 = 0.9754; const SVG_FALLBACK_HORIZONTAL_SCALE: f32 = 0.972; const GROUP_DRAWING_TOP_OFFSET: f32 = 0.96; const ROW_HEIGHT: f32 = 15.0; @@ -72,6 +73,8 @@ struct SheetPageSetup { fit_to_width: bool, fit_to_height: bool, horizontal_centered: bool, + vertical_centered: bool, + legacy_vml_drawing: bool, o365_printer_fallback: bool, } @@ -88,6 +91,8 @@ impl Default for SheetPageSetup { fit_to_width: false, fit_to_height: false, horizontal_centered: false, + vertical_centered: false, + legacy_vml_drawing: false, o365_printer_fallback: false, } } @@ -114,6 +119,7 @@ struct CellStyle { vertical_alignment: VerticalAlignment, indent: f32, wrap_text: bool, + stacked_text: bool, preferred_font: Option<&'static str>, } @@ -152,6 +158,7 @@ enum NumberFormat { General, DateMonthDayYear, DateDayShortMonthYear, + DateYearMonthDay, PercentageZeroDecimals, PercentageTwoDecimals, ThousandsZeroDecimals, @@ -192,6 +199,7 @@ impl Default for CellStyle { vertical_alignment: VerticalAlignment::Bottom, indent: 0.0, wrap_text: false, + stacked_text: false, preferred_font: None, } } @@ -279,6 +287,8 @@ fn parse_number_format( } } else if unescaped.contains("dd-mmm-yyyy") { NumberFormat::DateDayShortMonthYear + } else if unescaped.contains("yyyy-mm-dd") { + NumberFormat::DateYearMonthDay } else if unescaped.contains("#,##0") && !unescaped.contains('.') { NumberFormat::ThousandsZeroDecimals } else { @@ -657,6 +667,14 @@ fn read_page_setup(sheet_xml: &str) -> Result { .find(|node| node.is_element() && node.tag_name().name() == "printOptions") .and_then(|node| node.attribute("horizontalCentered")) .is_some_and(|value| matches!(value, "1" | "true")); + let vertical_centered = document + .descendants() + .find(|node| node.is_element() && node.tag_name().name() == "printOptions") + .and_then(|node| node.attribute("verticalCentered")) + .is_some_and(|value| matches!(value, "1" | "true")); + let legacy_vml_drawing = document + .descendants() + .any(|node| node.has_tag_name("legacyDrawing")); let margin_top = margin("top", MARGIN_TOP); Ok(SheetPageSetup { @@ -674,6 +692,8 @@ fn read_page_setup(sheet_xml: &str) -> Result { fit_to_width: fit_to_page && fit_to_width > 0, fit_to_height: fit_to_page && fit_to_height > 0, horizontal_centered, + vertical_centered, + legacy_vml_drawing, o365_printer_fallback: false, }) } @@ -892,25 +912,32 @@ fn read_sheet_images( default_row_height: f32, ) -> Result> { let sheet = roxmltree::Document::parse(sheet_xml)?; + let mut images = read_legacy_drawing_images( + archive, + sheet_path, + &sheet, + column_widths, + rows, + default_row_height, + )?; let Some(drawing_id) = sheet .descendants() .find(|node| node.has_tag_name("drawing")) .and_then(relationship_id) else { - return Ok(Vec::new()); + return Ok(images); }; let sheet_rels = read_part_relationships(archive, sheet_path)?; let Some(drawing_target) = sheet_rels.get(&drawing_id) else { - return Ok(Vec::new()); + return Ok(images); }; let drawing_path = resolve_part_target(sheet_path, drawing_target); let Some(drawing_xml) = read_zip_text(archive, &drawing_path)? else { - return Ok(Vec::new()); + return Ok(images); }; let drawing_rels = read_part_relationships(archive, &drawing_path)?; let theme_colors = read_theme_colors(archive)?; let drawing = roxmltree::Document::parse(&drawing_xml)?; - let mut images = Vec::new(); for anchor in drawing .descendants() @@ -988,6 +1015,276 @@ fn read_sheet_images( Ok(images) } +fn read_legacy_drawing_images( + archive: &mut ZipArchive, + sheet_path: &str, + sheet: &roxmltree::Document<'_>, + column_widths: &[f32], + rows: &[RowData], + default_row_height: f32, +) -> Result> { + let Some(vml_id) = sheet + .descendants() + .find(|node| node.has_tag_name("legacyDrawing")) + .and_then(relationship_id) + else { + return Ok(Vec::new()); + }; + let sheet_rels = read_part_relationships(archive, sheet_path)?; + let Some(vml_target) = sheet_rels.get(&vml_id) else { + return Ok(Vec::new()); + }; + let vml_path = resolve_part_target(sheet_path, vml_target); + let Some(vml_xml) = read_zip_text(archive, &vml_path)? else { + return Ok(Vec::new()); + }; + let vml_rels = read_part_relationships(archive, &vml_path)?; + let vml = roxmltree::Document::parse(&vml_xml)?; + let mut images = Vec::new(); + + for shape in vml.descendants().filter(|node| node.has_tag_name("shape")) { + let Some(client_data) = shape + .children() + .find(|node| node.has_tag_name("ClientData")) + .filter(|node| node.attribute("ObjectType") == Some("Pict")) + else { + continue; + }; + let Some(image_data) = shape.children().find(|node| node.has_tag_name("imagedata")) else { + continue; + }; + let Some(image_id) = image_data + .attributes() + .find(|attribute| attribute.name() == "relid") + .map(|attribute| attribute.value()) + else { + continue; + }; + let Some(image_target) = vml_rels.get(image_id) else { + continue; + }; + let Some(anchor) = client_data + .children() + .find(|node| node.has_tag_name("Anchor")) + .and_then(|node| node.text()) + .and_then(parse_vml_anchor) + else { + continue; + }; + let style = shape.attribute("style").unwrap_or_default(); + let Some(width) = parse_vml_style_points(style, "width").filter(|value| *value > 0.0) + else { + continue; + }; + let Some(height) = parse_vml_style_points(style, "height").filter(|value| *value > 0.0) + else { + continue; + }; + let image_path = resolve_part_target(&vml_path, image_target); + let Some(bytes) = read_zip_bytes(archive, &image_path)? else { + continue; + }; + let (data, pixel_width, pixel_height) = if image_path.to_ascii_lowercase().ends_with(".emf") + { + let crop = [ + parse_vml_crop(image_data.attribute("cropleft")), + parse_vml_crop(image_data.attribute("croptop")), + parse_vml_crop(image_data.attribute("cropright")), + parse_vml_crop(image_data.attribute("cropbottom")), + ]; + let Some(rgba) = rasterize_emf(&bytes, height, crop) else { + continue; + }; + let pixel_width = rgba.width().min(u16::MAX.into()) as u16; + let pixel_height = rgba.height().min(u16::MAX.into()) as u16; + ( + SheetImageData::Rgba(rgba.into_raw()), + pixel_width, + pixel_height, + ) + } else if let Some((pixel_width, pixel_height)) = jpeg_dimensions(&bytes) { + (SheetImageData::Jpeg(bytes), pixel_width, pixel_height) + } else { + let Ok(decoded) = image::load_from_memory(&bytes) else { + continue; + }; + let rgba = decoded.into_rgba8(); + let pixel_width = rgba.width().min(u16::MAX.into()) as u16; + let pixel_height = rgba.height().min(u16::MAX.into()) as u16; + ( + SheetImageData::Rgba(rgba.into_raw()), + pixel_width, + pixel_height, + ) + }; + let col = anchor[0]; + let row = anchor[2]; + let col_offset = + column_widths.get(col).copied().unwrap_or(COL_WIDTH) * anchor[1] as f32 / 1024.0; + let row_height = rows + .iter() + .find(|candidate| candidate.index == row) + .map(|candidate| candidate.height) + .unwrap_or(default_row_height); + let row_offset = row_height * anchor[3] as f32 / 256.0; + images.push(SheetImage { + data, + pixel_width, + pixel_height, + col, + row, + col_offset, + row_offset, + width, + height, + foreground: true, + }); + } + + Ok(images) +} + +fn parse_vml_anchor(value: &str) -> Option<[usize; 8]> { + value + .split(',') + .map(|part| part.trim().parse::().ok()) + .collect::>>()? + .try_into() + .ok() +} + +fn parse_vml_style_points(style: &str, property: &str) -> Option { + style.split(';').find_map(|declaration| { + let (name, value) = declaration.split_once(':')?; + if !name.trim().eq_ignore_ascii_case(property) { + return None; + } + let value = value.trim(); + let number = value.get(..value.len().checked_sub(2)?)?; + value + .get(value.len() - 2..) + .filter(|unit| unit.eq_ignore_ascii_case("pt"))?; + number.parse().ok() + }) +} + +fn parse_vml_crop(value: Option<&str>) -> f32 { + value + .and_then(|value| value.strip_suffix('f').or_else(|| value.strip_suffix('F'))) + .and_then(|value| value.parse::().ok()) + .map(|value| (value / 65_536.0).clamp(0.0, 1.0)) + .unwrap_or(0.0) +} + +#[cfg(not(windows))] +fn rasterize_emf(_data: &[u8], _display_height: f32, _crop: [f32; 4]) -> Option { + None +} + +#[cfg(windows)] +fn rasterize_emf(data: &[u8], display_height: f32, crop: [f32; 4]) -> Option { + use std::ffi::c_void; + use std::mem::size_of; + use std::ptr::null_mut; + + use windows_sys::Win32::Foundation::RECT; + use windows_sys::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateDIBSection, DeleteDC, DeleteEnhMetaFile, DeleteObject, + PlayEnhMetaFile, SelectObject, SetEnhMetaFileBits, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, + DIB_RGB_COLORS, + }; + + if data.len() < 24 || data.len() > u32::MAX as usize || display_height <= 0.0 { + return None; + } + let read_i32 = |offset: usize| { + Some(i32::from_le_bytes( + data.get(offset..offset + 4)?.try_into().ok()?, + )) + }; + let bounds_width = read_i32(16)?.checked_sub(read_i32(8)?)?.unsigned_abs(); + let bounds_height = read_i32(20)?.checked_sub(read_i32(12)?)?.unsigned_abs(); + if bounds_width == 0 || bounds_height == 0 { + return None; + } + let visible_height = (1.0 - crop[1] - crop[3]).max(0.01); + let raster_height = (display_height * 300.0 / 72.0 / visible_height) + .ceil() + .clamp(32.0, 4096.0) as u32; + let raster_width = (raster_height as f32 * bounds_width as f32 / bounds_height as f32) + .round() + .clamp(32.0, 4096.0) as u32; + + unsafe { + let metafile = SetEnhMetaFileBits(data.len() as u32, data.as_ptr()); + if metafile.is_null() { + return None; + } + let dc = CreateCompatibleDC(null_mut()); + if dc.is_null() { + DeleteEnhMetaFile(metafile); + return None; + } + let mut bitmap_info: BITMAPINFO = std::mem::zeroed(); + bitmap_info.bmiHeader = BITMAPINFOHEADER { + biSize: size_of::() as u32, + biWidth: raster_width as i32, + biHeight: -(raster_height as i32), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + ..std::mem::zeroed() + }; + let mut bits: *mut c_void = null_mut(); + let bitmap = CreateDIBSection(dc, &bitmap_info, DIB_RGB_COLORS, &mut bits, null_mut(), 0); + if bitmap.is_null() || bits.is_null() { + DeleteDC(dc); + DeleteEnhMetaFile(metafile); + return None; + } + let old_bitmap = SelectObject(dc, bitmap); + let byte_len = raster_width as usize * raster_height as usize * 4; + std::ptr::write_bytes(bits, 255, byte_len); + let destination = RECT { + left: 0, + top: 0, + right: raster_width as i32, + bottom: raster_height as i32, + }; + let rendered = PlayEnhMetaFile(dc, metafile, &destination) != 0; + let bgra = std::slice::from_raw_parts(bits.cast::(), byte_len); + let mut rgba = Vec::with_capacity(byte_len); + for pixel in bgra.chunks_exact(4) { + rgba.extend_from_slice(&[pixel[2], pixel[1], pixel[0], 255]); + } + SelectObject(dc, old_bitmap); + DeleteObject(bitmap); + DeleteDC(dc); + DeleteEnhMetaFile(metafile); + if !rendered { + return None; + } + + let source = image::RgbaImage::from_raw(raster_width, raster_height, rgba)?; + let crop_x = (raster_width as f32 * crop[0]).round() as u32; + let crop_y = (raster_height as f32 * crop[1]).round() as u32; + let crop_width = (raster_width as f32 * (1.0 - crop[0] - crop[2])) + .round() + .max(1.0) as u32; + let crop_height = (raster_height as f32 * visible_height).round().max(1.0) as u32; + Some( + image::imageops::crop_imm( + &source, + crop_x.min(raster_width - 1), + crop_y.min(raster_height - 1), + crop_width.min(raster_width.saturating_sub(crop_x).max(1)), + crop_height.min(raster_height.saturating_sub(crop_y).max(1)), + ) + .to_image(), + ) + } +} + fn read_two_cell_shape( anchor: roxmltree::Node<'_, '_>, theme_colors: &[PdfColor], @@ -1721,6 +2018,8 @@ fn read_styles( .and_then(|border_id| borders.get(border_id).copied()) .unwrap_or_default(); let alignment = xf.children().find(|node| node.has_tag_name("alignment")); + let stacked_text = + is_stacked_text(alignment.and_then(|node| node.attribute("textRotation"))); CellStyle { bold: font.bold, italic: font.italic, @@ -1747,6 +2046,7 @@ fn read_styles( wrap_text: alignment .and_then(|node| node.attribute("wrapText")) .is_some_and(|value| matches!(value, "1" | "true")), + stacked_text, preferred_font: font.preferred_font, } }) @@ -2505,6 +2805,7 @@ fn xlsx_preferred_font(font_name: Option<&str>) -> Option<&'static str> { "garamond" => Some("gara"), "grandview" => Some("grandview"), "grandview display" => Some("grandviewdisplay"), + "kaiti" | "stkaiti" | "华文楷体" | "楷体" => Some("simkai"), "palatino linotype" => Some("bookos"), "tw cen mt" => Some("tcm_____"), "verdana" => Some("verdana"), @@ -2792,6 +3093,11 @@ fn merged_cell_borders(rows: &[RowData], merge_range: &MergeRange) -> CellBorder } } +fn merged_cell_render_row(merge_range: &MergeRange, page_start_row: usize) -> Option { + let render_row = merge_range.start_row.max(page_start_row); + (render_row <= merge_range.end_row).then_some(render_row) +} + fn parse_rgb_color(value: &str) -> Option { let rgb = value.get(value.len().checked_sub(6)?..)?; let number = u32::from_str_radix(rgb, 16).ok()?; @@ -2819,6 +3125,22 @@ fn parse_vertical_alignment(value: Option<&str>) -> VerticalAlignment { } } +fn is_stacked_text(text_rotation: Option<&str>) -> bool { + text_rotation == Some("255") +} + +fn should_use_kaiti_fallback(text: &str, style: CellStyle) -> bool { + style.preferred_font == Some("simkai") && style.stacked_text && !text.is_empty() +} + +fn is_kaiti_slash_date(text: &str, style: CellStyle) -> bool { + style.preferred_font == Some("simkai") + && text.matches('/').count() == 2 + && text + .chars() + .all(|character| character.is_ascii_digit() || character == '/') +} + fn read_shared_strings( archive: &mut ZipArchive, ) -> Result> { @@ -3114,6 +3436,7 @@ fn format_numeric_value(value: f64, source: &str, number_format: NumberFormat) - match number_format { NumberFormat::DateMonthDayYear => format_excel_date(value), NumberFormat::DateDayShortMonthYear => format_excel_date_day_short_month_year(value), + NumberFormat::DateYearMonthDay => format_excel_date_year_month_day(value), NumberFormat::PercentageZeroDecimals => format!("{:.0}%", value * 100.0), NumberFormat::PercentageTwoDecimals => format!("{:.2}%", value * 100.0), NumberFormat::ThousandsZeroDecimals => format_thousands_zero_decimals(value), @@ -3161,6 +3484,11 @@ fn format_excel_date_day_short_month_year(value: f64) -> String { format!("{day:02}-{month_name}-{year:04}") } +fn format_excel_date_year_month_day(value: f64) -> String { + let (year, month, day) = excel_date_parts(value); + format!("{year:04}-{month:02}-{day:02}") +} + fn excel_date_parts(value: f64) -> (i64, i64, i64) { let mut days = value.floor() as i64 - 25_569; days += 719_468; @@ -3582,6 +3910,14 @@ fn render_sheet( let horizontal_scale = xlsx_horizontal_scale(sheet); let horizontal_geometry_scale = content_scale * horizontal_scale + * if sheet.page_setup.horizontal_centered + && sheet.page_setup.vertical_centered + && sheet.page_setup.legacy_vml_drawing + { + CENTERED_VML_HORIZONTAL_SCALE + } else { + 1.0 + } * if sheet.page_setup.o365_printer_fallback { O365_PRINTER_FALLBACK_HORIZONTAL_SCALE } else { @@ -3788,6 +4124,9 @@ fn render_sheet_columns( doc.add_page(page_size.width, page_size.height); let mut y = page_size.height - margin_top; let mut next_row_index = 0; + let mut page_ranges = Vec::new(); + let mut page_content_top = y; + let mut page_start_row = 0; for row in &sheet.rows { let crossed_break = row_break_in_range( @@ -3797,9 +4136,12 @@ fn render_sheet_columns( row.index, ); if let Some(break_index) = crossed_break.filter(|_| y < page_size.height - margin_top) { + page_ranges.push((page_index, page_content_top, y)); page_index = doc.pages().len(); doc.add_page(page_size.width, page_size.height); y = page_size.height - margin_top; + page_content_top = y; + page_start_row = break_index; repeat_print_title_rows( doc, sheet, @@ -3832,9 +4174,12 @@ fn render_sheet_columns( let row_height = row.height * row_scale; if sheet.print_title_rows.is_none() && y - row_height < margin_bottom { + page_ranges.push((page_index, page_content_top, y)); page_index = doc.pages().len(); doc.add_page(page_size.width, page_size.height); y = page_size.height - margin_top; + page_content_top = y; + page_start_row = row.index; repeat_print_title_rows( doc, sheet, @@ -3865,11 +4210,13 @@ fn render_sheet_columns( content_left, row_scale, page_index, + page_start_row, y, row, ); next_row_index = row.index + 1; } + page_ranges.push((page_index, page_content_top, y)); let page = doc.page_mut(first_page_index).expect("page index is valid"); for (image, image_id) in sheet.images.iter().zip(image_ids).filter(|(image, _)| { @@ -3899,6 +4246,23 @@ fn render_sheet_columns( image.height * row_scale, ); } + + if sheet.page_setup.vertical_centered { + let printable_top = page_size.height - margin_top; + for (page_index, content_top, content_bottom) in page_ranges { + let content_height = content_top - content_bottom; + let printable_height = printable_top - margin_bottom; + if content_height >= printable_height { + continue; + } + let offset = (margin_bottom + printable_top - content_bottom - content_top) / 2.0; + if offset.abs() >= 0.01 { + doc.page_mut(page_index) + .expect("page index is valid") + .translate_y(offset); + } + } + } } fn row_break_in_range( @@ -4001,6 +4365,7 @@ fn repeat_print_title_rows( content_left, row_scale, page_index, + title_start, *y, row, ); @@ -4021,6 +4386,7 @@ fn render_xlsx_row( content_left: f32, row_scale: f32, page_index: usize, + page_start_row: usize, y: f32, row: &RowData, ) { @@ -4076,7 +4442,9 @@ fn render_xlsx_row( && merge.start_col <= column_index && merge.end_col >= column_index }); - if merge.is_some_and(|merge| row.index > merge.start_row) { + if merge + .is_some_and(|merge| merged_cell_render_row(merge, page_start_row) != Some(row.index)) + { cell_x += column_widths[column_index]; continue; } @@ -4086,14 +4454,28 @@ fn render_xlsx_row( continue; } let source_column = merge.map(|merge| merge.start_col).unwrap_or(column_index); - let cell = row.cells.get(source_column).unwrap_or(&empty_cell); + let is_merge_continuation = merge.is_some_and(|merge| merge.start_row < page_start_row); + let source_row = merge + .filter(|_| is_merge_continuation) + .and_then(|merge| { + sheet + .rows + .iter() + .find(|candidate| candidate.index == merge.start_row) + }) + .unwrap_or(row); + let cell = source_row.cells.get(source_column).unwrap_or(&empty_cell); + if is_merge_continuation && cell.text.is_empty() && cell.style.fill_color.is_none() { + cell_x += column_widths[column_index]; + continue; + } let merge_end = merge .map(|merge| (merge.end_col + 1).min(column_end)) .unwrap_or(column_index + 1); let cell_width = column_widths[column_index..merge_end].iter().sum::(); let cell_height = merge .map(|merge| { - (merge.start_row..=merge.end_row) + (merge.start_row.max(page_start_row)..=merge.end_row) .map(|row_index| { sheet .rows @@ -4231,22 +4613,38 @@ fn render_xlsx_row( } else { 1.0 }; - let font_size = cell.style.font_size * content_scale * font_scale; + let mut text_style = cell.style; + let wrap_padding = if is_kaiti_slash_date(&cell.text, text_style) { + 0.0 + } else { + 6.0 + }; + if text_style.preferred_font == Some("simkai") + && !should_use_kaiti_fallback(&cell.text, text_style) + { + text_style.preferred_font = None; + } + let font_size = text_style.font_size * content_scale * font_scale; let indent_width = styled_text_width_with_font( - &"000".repeat(cell.style.indent as usize), + &"000".repeat(text_style.indent as usize), CELL_FONT_SIZE * content_scale * font_scale, false, false, None, ); + let rendered_text = if is_merge_continuation { + "" + } else { + &cell.text + }; let text = if merge.is_some_and(|merge| merge.start_col < column_start) { String::new() } else { - cell.text.replace(['\r', '\n'], " ") + rendered_text.replace(['\r', '\n'], " ") }; - let align_right = cell.is_numeric || has_rtl_base_direction(&cell.text); - let overflows_left = cell.style.horizontal_alignment == HorizontalAlignment::Right - || (cell.style.horizontal_alignment == HorizontalAlignment::General && align_right); + let align_right = cell.is_numeric || has_rtl_base_direction(rendered_text); + let overflows_left = text_style.horizontal_alignment == HorizontalAlignment::Right + || (text_style.horizontal_alignment == HorizontalAlignment::General && align_right); let (clip_offset, clip_width, overflow_is_blocked) = if merge.is_none() { text_overflow_region( sheet, @@ -4260,43 +4658,54 @@ fn render_xlsx_row( } else { (0.0, cell_width, false) }; - let lines = if cell.style.wrap_text { + let lines = if text_style.stacked_text { + rendered_text + .replace("\r\n", "\n") + .replace('\r', "\n") + .split('\n') + .map(str::to_owned) + .collect() + } else if text_style.wrap_text { wrap_cell_text_with_font( - &cell.text, - (cell_width - 6.0 - indent_width).max(1.0), + rendered_text, + (cell_width - wrap_padding - indent_width).max(1.0), font_size, - cell.style.bold, - cell.style.italic, - cell.style.preferred_font, + text_style.bold, + text_style.italic, + text_style.preferred_font, ) } else { vec![text] }; - let max_text_width = lines - .iter() - .map(|line| { - styled_text_width_with_font( - line, - font_size, - cell.style.bold, - cell.style.italic, - cell.style.preferred_font, - ) - }) - .fold(0.0, f32::max); + let max_text_width = if text_style.stacked_text { + font_size * 1.2 * lines.len() as f32 + } else { + lines + .iter() + .map(|line| { + styled_text_width_with_font( + line, + font_size, + text_style.bold, + text_style.italic, + text_style.preferred_font, + ) + }) + .fold(0.0, f32::max) + }; let should_clip = - cell.style.wrap_text || (max_text_width > clip_width && overflow_is_blocked); + text_style.wrap_text || (max_text_width > clip_width && overflow_is_blocked); pending_text.push(PendingCellText { cell_x, cell_y, cell_width, cell_height, - clip_x: if cell.style.wrap_text { + clip_x: if text_style.wrap_text { cell_x } else { cell_x + clip_offset }, - clip_width: if cell.style.wrap_text { + clip_width: if text_style.wrap_text { cell_width } else { clip_width @@ -4305,12 +4714,12 @@ fn render_xlsx_row( lines, font_size, indent_width, - style: cell.style, + style: text_style, has_border: cell_borders.any(), is_multi_row_merge: cell_height > row_height + 0.1, align_right, accounting_value: matches!(cell.style.number_format, NumberFormat::DollarAccounting) - .then(|| cell.text.strip_prefix("$ ").map(str::to_owned)) + .then(|| rendered_text.strip_prefix("$ ").map(str::to_owned)) .flatten(), }); cell_x += column_widths[column_index]; @@ -4321,7 +4730,17 @@ fn render_xlsx_row( page.push_clip(text.clip_x, text.cell_y, text.clip_width, text.cell_height); } let line_height = text.font_size * 1.2; - let block_height = line_height * text.lines.len() as f32; + let line_count = text.lines.len(); + let block_height = if text.style.stacked_text { + text.lines + .iter() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0) as f32 + * line_height + } else { + line_height * line_count as f32 + }; let baseline_offset = text.font_size * 0.2; let lowest_baseline = match text.style.vertical_alignment { VerticalAlignment::Top => { @@ -4346,7 +4765,6 @@ fn render_xlsx_row( } else { 0.0 }; - let line_count = text.lines.len(); if let Some(value) = text.accounting_value { let text_y = lowest_baseline; page.add_styled_text( @@ -4385,6 +4803,60 @@ fn render_xlsx_row( } continue; } + if text.style.stacked_text { + let column_count = text.lines.len(); + let block_width = line_height * column_count as f32; + let block_x = match text.style.horizontal_alignment { + HorizontalAlignment::Center | HorizontalAlignment::General => { + text.cell_x + (text.cell_width - block_width).max(0.0) / 2.0 + } + HorizontalAlignment::Right => text.cell_x + text.cell_width - block_width - 3.0, + HorizontalAlignment::Left => text.cell_x + 3.0, + }; + for (column_index, column) in text.lines.into_iter().enumerate() { + let characters = column.chars().collect::>(); + let column_height = line_height * characters.len() as f32; + let lowest_column_baseline = match text.style.vertical_alignment { + VerticalAlignment::Top => { + text.cell_y + (text.cell_height - column_height).max(0.0) + baseline_offset + } + VerticalAlignment::Center => { + text.cell_y + + (text.cell_height - column_height).max(0.0) / 2.0 + + baseline_offset + } + VerticalAlignment::Bottom => text.cell_y + baseline_offset.max(1.0), + }; + let column_x = block_x + (column_count - column_index - 1) as f32 * line_height; + for (character_index, character) in characters.iter().enumerate() { + let character = character.to_string(); + let character_width = styled_text_width_with_font( + &character, + text.font_size, + text.style.bold, + text.style.italic, + text.style.preferred_font, + ); + page.add_styled_text( + character, + column_x + (line_height - character_width).max(0.0) / 2.0, + lowest_column_baseline + + (characters.len() - character_index - 1) as f32 * line_height, + text.font_size, + PdfTextStyle { + color: text.style.font_color, + bold: text.style.bold, + italic: text.style.italic, + preferred_font: text.style.preferred_font, + }, + ); + } + } + if text.should_clip { + page.pop_clip(); + } + continue; + } for (line_index, line) in text.lines.into_iter().enumerate() { let text_width = styled_text_width_with_font( &line, @@ -4518,6 +4990,7 @@ fn has_rtl_base_direction(text: &str) -> bool { #[cfg(test)] mod tests { use std::collections::HashMap; + use std::io::{Cursor, Write}; use super::{ apply_color_tint, apply_print_area, apply_sheet_conditional_formats, @@ -4527,8 +5000,9 @@ mod tests { format_thousands_two_decimals, has_rtl_base_direction, indexed_color, jpeg_dimensions, merged_cell_borders, parse_cell_borders, parse_conditional_text_search, parse_header_footer_sections, parse_horizontal_alignment, parse_number_format, - parse_print_area, parse_rgb_color, parse_vertical_alignment, - parse_windows_devmode_page_size, read_column_widths, read_merge_ranges, read_page_setup, + parse_print_area, parse_rgb_color, parse_vertical_alignment, parse_vml_anchor, + parse_vml_crop, parse_vml_style_points, parse_windows_devmode_page_size, + read_column_widths, read_legacy_drawing_images, read_merge_ranges, read_page_setup, read_row_breaks, read_sheet_footer, read_sheet_rows, read_two_cell_shape, relationship_id, rendered_column_count, spreadsheet_theme_colors, text_overflow_region, trim_trailing_empty_rows, wrap_cell_text, xlsx_horizontal_scale, xlsx_left_offset, @@ -4537,6 +5011,8 @@ mod tests { VerticalAlignment, XlsxStyles, }; use crate::{PageSize, PdfColor}; + use zip::write::SimpleFileOptions; + use zip::ZipArchive; #[test] fn parses_excel_column_references() { @@ -4552,6 +5028,79 @@ mod tests { assert_eq!(cell_position("SUM(A1:A2)"), None); } + #[test] + fn reads_legacy_vml_picture_geometry() { + let sheet_xml = r#""#; + let relationships_xml = r#""#; + let vml_xml = r#"1, 512, 2, 128, 3, 0, 4, 0"#; + let vml_relationships_xml = r#""#; + let image = image::RgbaImage::from_pixel(2, 1, image::Rgba([10, 20, 30, 255])); + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let mut output = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut output); + for (path, data) in [ + ( + "xl/worksheets/_rels/sheet1.xml.rels", + relationships_xml.as_bytes(), + ), + ("xl/drawings/vmlDrawing1.vml", vml_xml.as_bytes()), + ( + "xl/drawings/_rels/vmlDrawing1.vml.rels", + vml_relationships_xml.as_bytes(), + ), + ("xl/media/image1.png", png.get_ref()), + ] { + writer + .start_file(path, SimpleFileOptions::default()) + .unwrap(); + writer.write_all(data).unwrap(); + } + writer.finish().unwrap(); + } + let mut archive = ZipArchive::new(Cursor::new(output.into_inner())).unwrap(); + let sheet = roxmltree::Document::parse(sheet_xml).unwrap(); + + let images = read_legacy_drawing_images( + &mut archive, + "xl/worksheets/sheet1.xml", + &sheet, + &[10.0, 20.0], + &[RowData { + index: 2, + height: 30.0, + cells: Vec::new(), + }], + 15.0, + ) + .unwrap(); + + assert_eq!(images.len(), 1); + assert_eq!(images[0].col, 1); + assert_eq!(images[0].row, 2); + assert_eq!(images[0].col_offset, 10.0); + assert_eq!(images[0].row_offset, 15.0); + assert_eq!(images[0].width, 120.0); + assert_eq!(images[0].height, 60.0); + assert_eq!((images[0].pixel_width, images[0].pixel_height), (2, 1)); + } + + #[test] + fn parses_vml_anchor_and_point_sizes() { + assert_eq!( + parse_vml_anchor(" 0, 5, 29, 8, 24, 30, 38, 19 "), + Some([0, 5, 29, 8, 24, 30, 38, 19]) + ); + assert_eq!( + parse_vml_style_points("position:absolute; WIDTH:521.4PT; height:147pt", "width"), + Some(521.4) + ); + assert!((parse_vml_crop(Some("32768f")) - 0.5).abs() < f32::EPSILON); + } + #[test] fn parses_conditional_text_search() { assert_eq!( @@ -4648,6 +5197,18 @@ mod tests { assert_eq!(page_setup.page_size.height, PageSize::LETTER.width); } + #[test] + fn reads_centered_legacy_vml_page_setup() { + let page_setup = read_page_setup( + r#""#, + ) + .expect("worksheet XML is valid"); + + assert!(page_setup.horizontal_centered); + assert!(page_setup.vertical_centered); + assert!(page_setup.legacy_vml_drawing); + } + #[test] fn reads_letter_landscape_from_windows_devmode() { let mut devmode = vec![0_u8; 220]; @@ -4968,6 +5529,7 @@ mod tests { let formats = HashMap::from([ (164, r#"dd\-mmm\-yyyy"#.to_owned()), (165, "#,##0".to_owned()), + (166, r#"yyyy\-mm\-dd;@"#.to_owned()), ]); assert!(matches!( @@ -4978,6 +5540,10 @@ mod tests { parse_number_format(Some("165"), &formats), super::NumberFormat::ThousandsZeroDecimals )); + assert!(matches!( + parse_number_format(Some("166"), &formats), + super::NumberFormat::DateYearMonthDay + )); assert!(matches!( parse_number_format(Some("3"), &formats), super::NumberFormat::ThousandsZeroDecimals @@ -4990,6 +5556,10 @@ mod tests { ), "24-Jan-2026" ); + assert_eq!( + super::format_numeric_value(45_758.0, "45758", super::NumberFormat::DateYearMonthDay), + "2025-04-11" + ); assert_eq!( super::format_numeric_value( 61_859.0, @@ -5287,6 +5857,38 @@ mod tests { assert_eq!(super::xlsx_preferred_font(Some("Arial")), Some("arial")); } + #[test] + fn maps_kaiti_family_names_to_installed_font() { + for name in ["华文楷体", "STKaiti", "KaiTi", "楷体"] { + assert_eq!(super::xlsx_preferred_font(Some(name)), Some("simkai")); + } + } + + #[test] + fn recognizes_stacked_text_rotation() { + assert!(super::is_stacked_text(Some("255"))); + assert!(!super::is_stacked_text(Some("90"))); + assert!(!super::is_stacked_text(None)); + } + + #[test] + fn limits_kaiti_fallback_to_stacked_text() { + let style = CellStyle { + preferred_font: Some("simkai"), + ..CellStyle::default() + }; + assert!(!super::should_use_kaiti_fallback("2025/11/25", style)); + assert!(super::is_kaiti_slash_date("2025/11/25", style)); + assert!(!super::is_kaiti_slash_date("普通文字", style)); + assert!(!super::should_use_kaiti_fallback("普通文字", style)); + assert!(super::should_use_kaiti_fallback( + "研发工程\n审核", + CellStyle { + stacked_text: true, + ..style + } + )); + } #[test] fn maps_invoice_fonts_to_installed_fonts() { assert_eq!(super::xlsx_preferred_font(Some("Garamond")), Some("gara")); @@ -5614,6 +6216,20 @@ mod tests { assert_eq!(borders.bottom, Some(PdfColor::BLACK)); } + #[test] + fn restarts_merged_cell_geometry_on_continuation_page() { + let merge = MergeRange { + start_col: 0, + end_col: 0, + start_row: 45, + end_row: 49, + }; + + assert_eq!(super::merged_cell_render_row(&merge, 0), Some(45)); + assert_eq!(super::merged_cell_render_row(&merge, 47), Some(47)); + assert_eq!(super::merged_cell_render_row(&merge, 50), None); + } + #[test] fn resolves_indexed_and_tinted_theme_colors() { assert_eq!(indexed_color(9), Some(PdfColor::new(1.0, 1.0, 1.0))); From e80097dc14aa5ac175cf492402133f6d684cb87b Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 01:08:36 +0800 Subject: [PATCH 2/4] chore(node): refresh Rust dependency lockfile Update the Node binding lockfile for minipdf 0.6.0 and its Windows GDI dependency. --- minipdf-node/Cargo.lock | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/minipdf-node/Cargo.lock b/minipdf-node/Cargo.lock index c83d150f..eea3435d 100644 --- a/minipdf-node/Cargo.lock +++ b/minipdf-node/Cargo.lock @@ -368,7 +368,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minipdf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "chrono", "flate2", @@ -379,6 +379,7 @@ dependencies = [ "thiserror", "ttf-parser", "unicode-bidi", + "windows-sys", "zip", ] @@ -861,6 +862,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "write-fonts" version = "0.43.0" From 2f8bcbba89fc1238d4bc41adc46d055dcfadf076 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 03:33:20 +0800 Subject: [PATCH 3/4] Unify cross-language benchmark runners Align all language benchmark entry points with the Rust benchmark contract: Microsoft 365 primary reference, LibreOffice auxiliary reference, 0.95 default score threshold, Suite/Format/MaxCases selection, SkipCandidate support, per-language artifact layout, and synchronized user/agent documentation. --- AGENTS.md | 15 + minipdf-rs/README.md | 4 +- scripts/Invoke-LanguageVisualBenchmark.ps1 | 420 +++++++++++---------- scripts/Run-Rust-Benchmark-Matrix.ps1 | 2 +- scripts/Run-Rust-Benchmark.ps1 | 281 +------------- tests/MiniPdf.Benchmark/README.md | 49 +-- 6 files changed, 269 insertions(+), 502 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 862e624f..7b60fee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,21 @@ scripts/Run-Benchmark_issues.ps1 -Filter "sa8000" # focused issue run scripts/Run-Benchmark_issues.ps1 -All # full issue benchmark ``` +### Cross-Language Visual Benchmarks + +Use the following form for .NET, Rust, Java, Go, Python, and Node: + +```powershell +scripts/Run--VisualBenchmark.ps1 -Suite -Format -MaxCases +``` + +Rust may also use `scripts/Run-Rust-Benchmark.ps1`; it forwards to the same +shared runner. Microsoft 365 is always the primary scored reference and +LibreOffice is the required auxiliary reference. `-Engine` does not switch the +primary reference. The default minimum score is `0.95`; use `-SkipCandidate` +only when the corresponding candidate PDFs already exist under +`artifacts/-benchmark///candidates`. + ## Classic Benchmark Refresh Workflow When updating all XLSX classic examples, canonical benchmark reports, or stale GitHub README benchmark images, use the `refresh-classic-benchmarks` skill (`/refresh-classic-benchmarks`). diff --git a/minipdf-rs/README.md b/minipdf-rs/README.md index 1ea87382..d352025d 100644 --- a/minipdf-rs/README.md +++ b/minipdf-rs/README.md @@ -174,7 +174,9 @@ Markdown and JSON reports, side-by-side images, and heatmaps under Microsoft 365 is the primary reference used for text, visual, page-count, and overall scores. LibreOffice is generated on every run as an auxiliary reference -and is included in the visual report without affecting those scores. +and is included in the visual report without affecting those scores. Both +references are required, the default minimum score is `0.95`, and +`-SkipCandidate` reuses an existing Rust candidate PDF. The matrix is generated at `artifacts/rust-benchmark/benchmark_matrix.md` and links to the fixture coverage and comparison reports from each run. diff --git a/scripts/Invoke-LanguageVisualBenchmark.ps1 b/scripts/Invoke-LanguageVisualBenchmark.ps1 index 5bfdbec4..174f7a03 100644 --- a/scripts/Invoke-LanguageVisualBenchmark.ps1 +++ b/scripts/Invoke-LanguageVisualBenchmark.ps1 @@ -1,39 +1,47 @@ <# .SYNOPSIS - Runs one MiniPdf implementation against the shared XLSX, DOCX, and PPTX corpus. + Runs one MiniPdf implementation against the repository's visual benchmark fixtures. .DESCRIPTION - Every language reads tests/MiniPdf.Benchmark/shared-office-corpus.json and writes - isolated candidates and reports below artifacts/benchmark/. LibreOffice - reference PDFs are shared by content hash across all language runs. + Uses Microsoft 365 as the primary scored reference and LibreOffice as the + required auxiliary reference. Outputs are isolated below + artifacts/-benchmark//. .EXAMPLE - .\scripts\Run-Java-VisualBenchmark.ps1 -Format all -MaxCasesPerFormat 1 - .\scripts\Run-Python-VisualBenchmark.ps1 -Format pptx -Filter "Asian Pacific" + .\scripts\Run-Java-VisualBenchmark.ps1 -Suite classic -Format xlsx -MaxCases 1 + .\scripts\Run-Python-VisualBenchmark.ps1 -Suite issue -Format pptx -Filter "Asian Pacific" #> param( [Parameter(Mandatory = $true)] [ValidateSet("dotnet", "rust", "java", "go", "python", "node")] [string]$Language, - [ValidateSet("all", "xlsx", "docx", "pptx")] - [string]$Format = "all", + [ValidateSet("classic", "issue")] + [string]$Suite = "classic", + [ValidateSet("xlsx", "docx", "pptx")] + [string]$Format = "xlsx", + [ValidateSet("o365", "office", "libre")] + [string]$Engine = "o365", [string]$Filter, - [int]$MaxCasesPerFormat = 0, + [int]$MaxCases = 0, [int]$MaxComparePages = 0, - [double]$MinimumScore = 0.0, - [string]$CorpusManifest = "tests/MiniPdf.Benchmark/shared-office-corpus.json", + [double]$MinimumScore = 0.95, + [string]$SourceDir, + [string]$CandidateDir, + [string]$ReferenceDir, + [string]$AuxiliaryReferenceDir, + [string]$ReportDir, [string]$ArtifactRoot, + [switch]$SkipCandidate, [switch]$SkipBuild, [switch]$SkipReference, - [switch]$SkipCompare, [switch]$ForceReference ) $ErrorActionPreference = "Stop" $RepoRoot = Split-Path -Parent $PSScriptRoot -if ($MaxCasesPerFormat -lt 0) { throw "MaxCasesPerFormat cannot be negative." } +if ($MaxCases -lt 0) { throw "MaxCases cannot be negative." } if ($MaxComparePages -lt 0) { throw "MaxComparePages cannot be negative." } if ($MinimumScore -lt 0.0 -or $MinimumScore -gt 1.0) { throw "MinimumScore must be between 0.0 and 1.0." @@ -132,93 +140,108 @@ function Test-Pdf([string]$Path) { return $Bytes.Length -ge 5 -and [System.Text.Encoding]::ASCII.GetString($Bytes, 0, 5) -eq "%PDF-" } -function Get-LibreOfficePath { - $Command = Get-Command soffice -ErrorAction SilentlyContinue - if ($Command) { - $ConsoleLauncher = [System.IO.Path]::ChangeExtension($Command.Source, ".com") - if ($IsWindows -and (Test-Path -LiteralPath $ConsoleLauncher)) { return $ConsoleLauncher } - return $Command.Source +$Defaults = @{ + "classic:xlsx" = @{ + Source = "tests/MiniPdf.Scripts/output" + LibreReference = "tests/MiniPdf.Benchmark/reference_pdfs" + OfficeReference = "tests/MiniPdf.Benchmark/office_pdfs" + LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs.py" + OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs.py" + SourceArgument = "--xlsx-dir" + OfficeLabel = "Microsoft 365 Excel Reference" } - $Candidates = @( - (Join-Path $env:ProgramFiles "LibreOffice/program/soffice.com"), - (Join-Path ${env:ProgramFiles(x86)} "LibreOffice/program/soffice.com"), - (Join-Path $env:ProgramFiles "LibreOffice/program/soffice.exe"), - (Join-Path ${env:ProgramFiles(x86)} "LibreOffice/program/soffice.exe") - ) - foreach ($Candidate in $Candidates) { - if ($Candidate -and (Test-Path -LiteralPath $Candidate)) { return $Candidate } + "classic:docx" = @{ + Source = "tests/MiniPdf.Scripts/output_docx" + LibreReference = "tests/MiniPdf.Benchmark/reference_pdfs_docx" + OfficeReference = "tests/MiniPdf.Benchmark/office_pdfs_docx" + LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs_docx.py" + OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs_docx.py" + SourceArgument = "--docx-dir" + OfficeLabel = "Microsoft 365 Word Reference" } - throw "LibreOffice soffice was not found. Install LibreOffice or use -SkipReference." -} - -$CorpusManifest = Resolve-RepoPath $CorpusManifest -if (-not (Test-Path -LiteralPath $CorpusManifest)) { - throw "Shared corpus manifest not found: $CorpusManifest" -} -$Corpus = Get-Content -LiteralPath $CorpusManifest -Raw | ConvertFrom-Json -$SelectedCases = [System.Collections.Generic.List[object]]::new() -$CaseSources = @{} - -foreach ($Source in $Corpus.sources) { - if ($Format -ne "all" -and $Source.format -ne $Format) { continue } - $SourceRoot = Resolve-RepoPath $Source.root - if (-not (Test-Path -LiteralPath $SourceRoot -PathType Container)) { - throw "Corpus source directory not found: $SourceRoot" + "issue:xlsx" = @{ + Source = "tests/Issue_Files/xlsx" + LibreReference = "tests/Issue_Files/reference_xlsx" + OfficeReference = "tests/Issue_Files/office_xlsx" + LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs.py" + OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs.py" + SourceArgument = "--xlsx-dir" + OfficeLabel = "Microsoft 365 Excel Reference" } - $Files = @(Get-ChildItem -LiteralPath $SourceRoot -File -Filter $Source.pattern | Where-Object { - -not $Filter -or $_.Name -like "*$Filter*" - } | Sort-Object Name) - if ($MaxCasesPerFormat -gt 0) { - $Files = @($Files | Select-Object -First $MaxCasesPerFormat) + "issue:docx" = @{ + Source = "tests/Issue_Files/docx" + LibreReference = "tests/Issue_Files/reference_docx" + OfficeReference = "tests/Issue_Files/office_docx" + LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs_docx.py" + OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs_docx.py" + SourceArgument = "--docx-dir" + OfficeLabel = "Microsoft 365 Word Reference" } - foreach ($File in $Files) { - $Hash = (Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - $SafeStem = ($File.BaseName -replace '[^A-Za-z0-9._-]', '_').Trim('_') - if (-not $SafeStem) { $SafeStem = "fixture" } - $CaseId = "$($Source.format)--$SafeStem--$($Hash.Substring(0, 24))" - if ($CaseSources.ContainsKey($CaseId)) { throw "Duplicate benchmark case id: $CaseId" } - $RelativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $File.FullName).Replace("\", "/") - $Case = [pscustomobject]@{ - name = $CaseId - case_id = $CaseId - display_name = $File.BaseName - suite = "shared-office" - format = $Source.format - source_path = $RelativePath - source_sha256 = $Hash - conversion_status = "pending" - conversion_exit_code = $null - candidate_exists = $false - reference_exists = $false - } - $SelectedCases.Add($Case) - $CaseSources[$CaseId] = $File.FullName + "issue:pptx" = @{ + Source = "tests/Issue_Files/pptx" + LibreReference = "tests/Issue_Files/reference_pptx" + OfficeReference = "tests/Issue_Files/office_pptx" + LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs_pptx.py" + OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs_pptx.py" + SourceArgument = "--pptx-dir" + OfficeLabel = "Microsoft 365 PowerPoint Reference" } } -if ($SelectedCases.Count -eq 0) { - throw "No shared corpus cases matched format=$Format filter='$Filter'." +$Config = $Defaults["$Suite`:$Format"] +if (-not $Config) { + throw "No $Language benchmark fixtures are configured for suite=$Suite format=$Format." +} +if ($Engine -eq "libre") { + Write-Warning "-Engine libre is retained for compatibility. Microsoft 365 remains the primary scored reference; LibreOffice is auxiliary." } -$ArtifactRoot = Resolve-RepoPath $(if ($ArtifactRoot) { $ArtifactRoot } else { "artifacts/benchmark" }) -$SharedRoot = Join-Path $ArtifactRoot "shared" -$LanguageRoot = Join-Path $ArtifactRoot $Language -$CandidateDir = Join-Path $LanguageRoot "candidates" -$ReportDir = Join-Path $LanguageRoot "report" -$ReferenceDir = Join-Path $SharedRoot "libreoffice-reference" -$ResolvedManifest = Join-Path $LanguageRoot "resolved-manifest.json" -$CoverageManifest = Join-Path $LanguageRoot "benchmark-coverage.json" -$ReferenceWorkDir = Join-Path $SharedRoot "reference-work" +$SourceDir = Resolve-RepoPath $(if ($SourceDir) { $SourceDir } else { $Config.Source }) +$ReferenceDir = Resolve-RepoPath $(if ($ReferenceDir) { $ReferenceDir } else { $Config.OfficeReference }) +$AuxiliaryReferenceDir = Resolve-RepoPath $(if ($AuxiliaryReferenceDir) { $AuxiliaryReferenceDir } else { $Config.LibreReference }) +$ArtifactRoot = Resolve-RepoPath $(if ($ArtifactRoot) { $ArtifactRoot } else { "artifacts/$Language-benchmark/$Suite/$Format" }) +$CandidateDir = Resolve-RepoPath $(if ($CandidateDir) { $CandidateDir } else { Join-Path $ArtifactRoot "candidates" }) +$ReportDir = Resolve-RepoPath $(if ($ReportDir) { $ReportDir } else { Join-Path $ArtifactRoot "report" }) +$ComparisonManifest = Join-Path $ReportDir "comparison_manifest.json" +$CoverageManifest = Join-Path $ReportDir "benchmark_coverage.json" -New-Item -ItemType Directory -Force -Path $CandidateDir, $ReportDir, $ReferenceDir, $ReferenceWorkDir | Out-Null -Write-Json ([pscustomobject]@{ - corpus = [System.IO.Path]::GetRelativePath($RepoRoot, $CorpusManifest).Replace("\", "/") - corpus_version = $Corpus.version - cases = $SelectedCases -}) $ResolvedManifest +$SourceFiles = @(Get-ChildItem -LiteralPath $SourceDir -File -Filter "*.$Format" | Where-Object { + -not $Filter -or $_.BaseName -like "*$Filter*" +} | Sort-Object Name) +if ($MaxCases -gt 0) { + $SourceFiles = @($SourceFiles | Select-Object -First $MaxCases) +} +if ($SourceFiles.Count -eq 0) { + throw "No .$Format files matched '$Filter' in $SourceDir" +} + +if (Test-Path -LiteralPath $ReportDir) { + Remove-Item -LiteralPath $ReportDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $CandidateDir, $ReferenceDir, $AuxiliaryReferenceDir, $ReportDir | Out-Null + +$SelectedCases = @($SourceFiles | ForEach-Object { + [pscustomobject]@{ + name = $_.BaseName + case_id = $_.BaseName + suite = $Suite + format = $Format + source_path = [System.IO.Path]::GetRelativePath($RepoRoot, $_.FullName).Replace("\", "/") + conversion_status = "pending" + conversion_exit_code = $null + candidate_exists = $false + reference_exists = $false + auxiliary_reference_exists = $false + } +}) +$CaseSources = @{} +for ($Index = 0; $Index -lt $SelectedCases.Count; $Index++) { + $CaseSources[$SelectedCases[$Index].case_id] = $SourceFiles[$Index].FullName +} +Write-Json ([pscustomobject]@{ cases = $SelectedCases }) $ComparisonManifest $Tools = @{} -if (-not $SkipBuild) { +if (-not $SkipCandidate -and -not $SkipBuild) { switch ($Language) { "dotnet" { $Tools.dotnet = Find-Command "dotnet" @@ -239,7 +262,7 @@ if (-not $SkipBuild) { } "go" { $Tools.go = Find-Go - $GoOutput = Join-Path $LanguageRoot $(if ($IsWindows) { "minipdf-go.exe" } else { "minipdf-go" }) + $GoOutput = Join-Path $ArtifactRoot $(if ($IsWindows) { "minipdf-go.exe" } else { "minipdf-go" }) Push-Location (Join-Path $RepoRoot "minipdf-go") try { & $Tools.go build -o $GoOutput ./cmd/minipdf } finally { Pop-Location } Assert-CommandSucceeded "Go CLI build" @@ -260,148 +283,147 @@ if (-not $SkipBuild) { } } -switch ($Language) { - "dotnet" { - if (-not $Tools.dotnet) { $Tools.dotnet = Find-Command "dotnet" } - $Tools.cli = Get-ChildItem (Join-Path $RepoRoot "src/MiniPdf.Cli/bin/Release") -Recurse -Filter "MiniPdf.Cli.dll" | - Where-Object FullName -Match 'net9\.0' | Select-Object -First 1 -ExpandProperty FullName - } - "rust" { - $RustName = if ($IsWindows) { "minipdf.exe" } else { "minipdf" } - $Tools.cli = Join-Path $RepoRoot "minipdf-rs/target/release/$RustName" - } - "java" { - if (-not $Tools.java) { $Tools.java = Find-JavaExecutable } - $Tools.cli = Get-ChildItem (Join-Path $RepoRoot "minipdf-java/minipdf-cli/target") -Filter "minipdf-cli-*.jar" | - Where-Object { $_.Name -notmatch '(sources|javadoc|original)' } | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName - } - "go" { - $Tools.cli = Join-Path $LanguageRoot $(if ($IsWindows) { "minipdf-go.exe" } else { "minipdf-go" }) - } - "python" { - if (-not $Tools.python) { - $Tools.python = Find-Python +if (-not $SkipCandidate) { + switch ($Language) { + "dotnet" { + if (-not $Tools.dotnet) { $Tools.dotnet = Find-Command "dotnet" } + $Tools.cli = Get-ChildItem (Join-Path $RepoRoot "src/MiniPdf.Cli/bin/Release") -Recurse -Filter "MiniPdf.Cli.dll" | + Where-Object FullName -Match 'net9\.0' | Select-Object -First 1 -ExpandProperty FullName } - $env:PYTHONPATH = Join-Path $RepoRoot "minipdf-python/src" + "rust" { + $RustName = if ($IsWindows) { "minipdf.exe" } else { "minipdf" } + $Tools.cli = Join-Path $RepoRoot "minipdf-rs/target/release/$RustName" + } + "java" { + if (-not $Tools.java) { $Tools.java = Find-JavaExecutable } + $Tools.cli = Get-ChildItem (Join-Path $RepoRoot "minipdf-java/minipdf-cli/target") -Filter "minipdf-cli-*.jar" | + Where-Object { $_.Name -notmatch '(sources|javadoc|original)' } | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + } + "go" { + $Tools.cli = Join-Path $ArtifactRoot $(if ($IsWindows) { "minipdf-go.exe" } else { "minipdf-go" }) + } + "python" { + if (-not $Tools.python) { $Tools.python = Find-Python } + $env:PYTHONPATH = Join-Path $RepoRoot "minipdf-python/src" + } + "node" { $Tools.node = Find-Command "node" } } - "node" { $Tools.node = Find-Command "node" } -} -if ($Language -notin @("python", "node") -and (-not $Tools.cli -or -not (Test-Path -LiteralPath $Tools.cli))) { - throw "$Language CLI artifact was not found. Run without -SkipBuild first." -} + if ($Language -notin @("python", "node") -and (-not $Tools.cli -or -not (Test-Path -LiteralPath $Tools.cli))) { + throw "$Language CLI artifact was not found. Run without -SkipBuild first." + } -foreach ($Case in $SelectedCases) { - $InputPath = $CaseSources[$Case.case_id] - $OutputPath = Join-Path $CandidateDir ($Case.name + ".pdf") - if (Test-Path -LiteralPath $OutputPath) { Remove-Item -LiteralPath $OutputPath -Force } - switch ($Language) { - "dotnet" { & $Tools.dotnet $Tools.cli $InputPath -o $OutputPath } - "rust" { & $Tools.cli $InputPath -o $OutputPath } - "java" { & $Tools.java -jar $Tools.cli $InputPath -o $OutputPath } - "go" { & $Tools.cli $InputPath -o $OutputPath } - "python" { & $Tools.python -m minipdf $InputPath -o $OutputPath } - "node" { - & $Tools.node -e "require(process.argv[1]).convertToPdf(process.argv[2], process.argv[3])" ` - (Join-Path $RepoRoot "minipdf-node") $InputPath $OutputPath + foreach ($Case in $SelectedCases) { + $InputPath = $CaseSources[$Case.case_id] + $OutputPath = Join-Path $CandidateDir ($Case.name + ".pdf") + if (Test-Path -LiteralPath $OutputPath) { Remove-Item -LiteralPath $OutputPath -Force } + switch ($Language) { + "dotnet" { & $Tools.dotnet $Tools.cli $InputPath -o $OutputPath } + "rust" { & $Tools.cli $InputPath -o $OutputPath } + "java" { & $Tools.java -jar $Tools.cli $InputPath -o $OutputPath } + "go" { & $Tools.cli $InputPath -o $OutputPath } + "python" { & $Tools.python -m minipdf $InputPath -o $OutputPath } + "node" { + & $Tools.node -e "require(process.argv[1]).convertToPdf(process.argv[2], process.argv[3])" ` + (Join-Path $RepoRoot "minipdf-node") $InputPath $OutputPath + } } + $Case.conversion_exit_code = $LASTEXITCODE + $Case.candidate_exists = Test-Pdf $OutputPath + $Case.conversion_status = if ($LASTEXITCODE -eq 0 -and $Case.candidate_exists) { "passed" } else { "failed" } + } +} else { + foreach ($Case in $SelectedCases) { + $OutputPath = Join-Path $CandidateDir ($Case.name + ".pdf") + $Case.candidate_exists = Test-Pdf $OutputPath + $Case.conversion_status = if ($Case.candidate_exists) { "passed" } else { "failed" } } - $Case.conversion_exit_code = $LASTEXITCODE - $Case.candidate_exists = Test-Pdf $OutputPath - $Case.conversion_status = if ($LASTEXITCODE -eq 0 -and $Case.candidate_exists) { "passed" } else { "failed" } } if (-not $SkipReference) { - $Soffice = Get-LibreOfficePath - foreach ($Case in $SelectedCases) { - $ReferencePath = Join-Path $ReferenceDir ($Case.name + ".pdf") - if ($ForceReference -or -not (Test-Pdf $ReferencePath)) { - Get-ChildItem -LiteralPath $ReferenceWorkDir -File -Filter "*.pdf" | Remove-Item -Force - $ProfileDir = Join-Path $ReferenceWorkDir ("profile-" + [System.Guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Force -Path $ProfileDir | Out-Null - $ProfileUri = ([System.Uri]$ProfileDir).AbsoluteUri - try { - & $Soffice --headless --norestore "-env:UserInstallation=$ProfileUri" ` - --convert-to pdf --outdir $ReferenceWorkDir $CaseSources[$Case.case_id] - Assert-CommandSucceeded "LibreOffice conversion for $($Case.source_path)" - } finally { - Remove-Item -LiteralPath $ProfileDir -Recurse -Force -ErrorAction SilentlyContinue - } - $GeneratedPath = Join-Path $ReferenceWorkDir ([System.IO.Path]::GetFileNameWithoutExtension($CaseSources[$Case.case_id]) + ".pdf") - if (-not (Test-Pdf $GeneratedPath)) { throw "LibreOffice did not produce a valid PDF for $($Case.source_path)." } - Move-Item -LiteralPath $GeneratedPath -Destination $ReferencePath -Force + $Python = Find-Python + $OfficeReferenceScript = Resolve-RepoPath $Config.OfficeReferenceScript + $LibreReferenceScript = Resolve-RepoPath $Config.LibreReferenceScript + $ReferenceFilters = if ($MaxCases -gt 0) { @($SourceFiles.BaseName) } else { @($Filter) } + foreach ($ReferenceFilter in $ReferenceFilters) { + $Providers = @( + [pscustomobject]@{ Script = $OfficeReferenceScript; Directory = $ReferenceDir; Label = $Config.OfficeLabel }, + [pscustomobject]@{ Script = $LibreReferenceScript; Directory = $AuxiliaryReferenceDir; Label = "LibreOffice" } + ) + foreach ($Provider in $Providers) { + $ReferenceArgs = @($Provider.Script, $Config.SourceArgument, $SourceDir, "--pdf-dir", $Provider.Directory) + if ($ReferenceFilter) { $ReferenceArgs += @("--filter", $ReferenceFilter) } + if ($ForceReference) { $ReferenceArgs += "--force" } + & $Python -X utf8 @ReferenceArgs + Assert-CommandSucceeded "$($Provider.Label) generation" } } } foreach ($Case in $SelectedCases) { $Case.reference_exists = Test-Pdf (Join-Path $ReferenceDir ($Case.name + ".pdf")) + $Case.auxiliary_reference_exists = Test-Pdf (Join-Path $AuxiliaryReferenceDir ($Case.name + ".pdf")) } $PassedConversions = @($SelectedCases | Where-Object conversion_status -eq "passed").Count $MissingReferences = @($SelectedCases | Where-Object reference_exists -eq $false).Count +$MissingAuxiliaryReferences = @($SelectedCases | Where-Object auxiliary_reference_exists -eq $false).Count $Coverage = [pscustomobject]@{ language = $Language - corpus_manifest = [System.IO.Path]::GetRelativePath($RepoRoot, $CorpusManifest).Replace("\", "/") - resolved_manifest = [System.IO.Path]::GetRelativePath($RepoRoot, $ResolvedManifest).Replace("\", "/") - fixture_scope = "shared-git-tracked-office-corpus" + suite = $Suite + format = $Format + reference_engine = "o365" + reference_label = $Config.OfficeLabel + auxiliary_reference_engine = "libreoffice" + auxiliary_reference_label = "LibreOffice (auxiliary)" + fixture_scope = "shared-on-disk-fixtures" + executes_dotnet_xunit = $false + max_compare_pages = $MaxComparePages selected_cases = $SelectedCases.Count passed_conversions = $PassedConversions failed_conversions = $SelectedCases.Count - $PassedConversions missing_references = $MissingReferences + missing_auxiliary_references = $MissingAuxiliaryReferences comparison_completed = $false comparison_results = 0 average_score = $null cases = $SelectedCases } Write-Json $Coverage $CoverageManifest -Write-Json ([pscustomobject]@{ - corpus = [System.IO.Path]::GetRelativePath($RepoRoot, $CorpusManifest).Replace("\", "/") - corpus_version = $Corpus.version - cases = $SelectedCases -}) $ResolvedManifest -if (-not $SkipCompare) { - if ($MissingReferences -gt 0) { - throw "$MissingReferences shared reference PDFs are missing. Run without -SkipReference." - } - $Python = if (Test-Path (Join-Path $RepoRoot ".venv/Scripts/python.exe")) { - Join-Path $RepoRoot ".venv/Scripts/python.exe" - } else { Find-Command "python" } - $CompareArgs = @( - (Join-Path $RepoRoot "tests/MiniPdf.Benchmark/compare_pdfs.py"), - "--minipdf-dir", $CandidateDir, - "--reference-dir", $ReferenceDir, - "--report-dir", $ReportDir, - "--manifest", $ResolvedManifest, - "--report-scope", "$Language-shared-office", - "--candidate-label", "$Language MiniPdf", - "--reference-label", "LibreOffice", - "--composite-images", - "--heatmaps" - ) - if ($MaxComparePages -gt 0) { $CompareArgs += @("--max-pages", $MaxComparePages) } - & $Python -X utf8 @CompareArgs - Assert-CommandSucceeded "$Language visual comparison" - $Results = @(Get-Content (Join-Path $ReportDir "comparison_report.json") -Raw | ConvertFrom-Json) - $Scores = @($Results | Where-Object { $null -ne $_.overall_score }) - $Coverage.comparison_completed = $true - $Coverage.comparison_results = ($Results | Measure-Object).Count - $Coverage.average_score = if ($Scores.Count -gt 0) { - ($Scores | Measure-Object -Property overall_score -Average).Average - } else { $null } - Write-Json $Coverage $CoverageManifest - $BelowThreshold = @($Results | Where-Object { $null -eq $_.overall_score -or $_.overall_score -lt $MinimumScore }) - if ($BelowThreshold.Count -gt 0) { - throw "$($BelowThreshold.Count) cases scored below MinimumScore=$MinimumScore." - } -} +$Python = Find-Python +$CompareArgs = @( + (Join-Path $RepoRoot "tests/MiniPdf.Benchmark/compare_pdfs.py"), + "--minipdf-dir", $CandidateDir, + "--reference-dir", $ReferenceDir, + "--auxiliary-dir", $AuxiliaryReferenceDir, + "--report-dir", $ReportDir, + "--manifest", $ComparisonManifest, + "--report-scope", "$Language-$Suite-$Format", + "--candidate-label", "$Language MiniPdf", + "--reference-label", $Config.OfficeLabel, + "--auxiliary-label", "LibreOffice", + "--composite-images", + "--heatmaps" +) +if ($MaxComparePages -gt 0) { $CompareArgs += @("--max-pages", $MaxComparePages) } +& $Python -X utf8 @CompareArgs +Assert-CommandSucceeded "$Language visual comparison" +$Results = @(Get-Content (Join-Path $ReportDir "comparison_report.json") -Raw | ConvertFrom-Json) +$Scores = @($Results | Where-Object { $null -ne $_.overall_score }) +$Coverage.comparison_completed = $true +$Coverage.comparison_results = ($Results | Measure-Object).Count +$Coverage.average_score = if ($Scores.Count -gt 0) { + ($Scores | Measure-Object -Property overall_score -Average).Average +} else { $null } +Write-Json $Coverage $CoverageManifest +$BelowThreshold = @($Results | Where-Object { $null -eq $_.overall_score -or $_.overall_score -lt $MinimumScore }) -Write-Host "$Language benchmark: selected=$($SelectedCases.Count), converted=$PassedConversions, missing references=$MissingReferences" +Write-Host "$Language benchmark: suite=$Suite format=$Format selected=$($SelectedCases.Count), converted=$PassedConversions, missing O365 references=$MissingReferences, missing LibreOffice references=$MissingAuxiliaryReferences" Write-Host "Coverage: $CoverageManifest" -if (-not $SkipCompare) { Write-Host "Report: $(Join-Path $ReportDir 'comparison_report.md')" } +Write-Host "Report: $(Join-Path $ReportDir 'comparison_report.md')" -if ($PassedConversions -ne $SelectedCases.Count) { - throw "$Language candidate conversion failed for $($SelectedCases.Count - $PassedConversions) cases." -} \ No newline at end of file +if ($PassedConversions -ne $SelectedCases.Count -or $MissingReferences -gt 0 -or $MissingAuxiliaryReferences -gt 0 -or $BelowThreshold.Count -gt 0) { + throw "$Language benchmark failed: conversion failures=$($SelectedCases.Count - $PassedConversions), missing O365 references=$MissingReferences, missing LibreOffice references=$MissingAuxiliaryReferences, below MinimumScore=$($BelowThreshold.Count)." +} diff --git a/scripts/Run-Rust-Benchmark-Matrix.ps1 b/scripts/Run-Rust-Benchmark-Matrix.ps1 index 477fc990..c7fe6d12 100644 --- a/scripts/Run-Rust-Benchmark-Matrix.ps1 +++ b/scripts/Run-Rust-Benchmark-Matrix.ps1 @@ -9,7 +9,7 @@ param( [int]$MaxComparePages = 1, - [double]$MinimumScore = 0, + [double]$MinimumScore = 0.95, [ValidateSet("o365", "office", "libre")] [string]$Engine = "o365", [switch]$SkipReference, diff --git a/scripts/Run-Rust-Benchmark.ps1 b/scripts/Run-Rust-Benchmark.ps1 index 5032e5aa..bdd5e07c 100644 --- a/scripts/Run-Rust-Benchmark.ps1 +++ b/scripts/Run-Rust-Benchmark.ps1 @@ -1,279 +1,2 @@ -<# -.SYNOPSIS - Compare Rust MiniPdf against the repository's shared visual fixtures and references. - -.DESCRIPTION - Reuses the same on-disk classic/issue fixtures and PDF comparison pipeline as - the .NET benchmarks. Microsoft 365 is always the primary scored reference, - while LibreOffice is generated and displayed as an auxiliary reference. It - does not execute C# xUnit tests. - -.EXAMPLE - .\scripts\Run-Rust-Benchmark.ps1 -Suite classic -Format xlsx - .\scripts\Run-Rust-Benchmark.ps1 -Suite classic -Format xlsx -Filter "classic180" -ForceReference - .\scripts\Run-Rust-Benchmark.ps1 -Suite classic -Format docx -Filter "classic01" - .\scripts\Run-Rust-Benchmark.ps1 -Suite issue -Format xlsx - .\scripts\Run-Rust-Benchmark.ps1 -Suite issue -Format docx -Filter "SA8000" - .\scripts\Run-Rust-Benchmark.ps1 -Suite issue -Format pptx -Filter "Asian Pacific" -#> - -param( - [ValidateSet("classic", "issue")] - [string]$Suite = "classic", - [ValidateSet("xlsx", "docx", "pptx")] - [string]$Format = "xlsx", - [ValidateSet("o365", "office", "libre")] - [string]$Engine = "o365", - [string]$Filter, - [int]$MaxCases = 0, - [int]$MaxComparePages = 0, - [string]$SourceDir, - [string]$CandidateDir, - [string]$ReferenceDir, - [string]$AuxiliaryReferenceDir, - [string]$ReportDir, - [double]$MinimumScore = 0.95, - [switch]$SkipCandidate, - [switch]$ForceReference, - [switch]$SkipReference -) - -$ErrorActionPreference = "Stop" -$RepoRoot = Split-Path -Parent $PSScriptRoot - -if ($MaxCases -lt 0) { - throw "MaxCases must be zero (all cases) or a positive number." -} -if ($MaxComparePages -lt 0) { - throw "MaxComparePages must be zero (all pages) or a positive number." -} - -function Resolve-RepoPath([string]$PathValue) { - if ([System.IO.Path]::IsPathRooted($PathValue)) { return $PathValue } - return Join-Path $RepoRoot $PathValue -} - -$Defaults = @{ - "classic:xlsx" = @{ - Source = "tests/MiniPdf.Scripts/output" - LibreReference = "tests/MiniPdf.Benchmark/reference_pdfs" - OfficeReference = "tests/MiniPdf.Benchmark/office_pdfs" - LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs.py" - OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs.py" - SourceArgument = "--xlsx-dir" - OfficeLabel = "Microsoft 365 Excel Reference" - } - "classic:docx" = @{ - Source = "tests/MiniPdf.Scripts/output_docx" - LibreReference = "tests/MiniPdf.Benchmark/reference_pdfs_docx" - OfficeReference = "tests/MiniPdf.Benchmark/office_pdfs_docx" - LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs_docx.py" - OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs_docx.py" - SourceArgument = "--docx-dir" - OfficeLabel = "Microsoft 365 Word Reference" - } - "issue:xlsx" = @{ - Source = "tests/Issue_Files/xlsx" - LibreReference = "tests/Issue_Files/reference_xlsx" - OfficeReference = "tests/Issue_Files/office_xlsx" - LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs.py" - OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs.py" - SourceArgument = "--xlsx-dir" - OfficeLabel = "Microsoft 365 Excel Reference" - } - "issue:docx" = @{ - Source = "tests/Issue_Files/docx" - LibreReference = "tests/Issue_Files/reference_docx" - OfficeReference = "tests/Issue_Files/office_docx" - LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs_docx.py" - OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs_docx.py" - SourceArgument = "--docx-dir" - OfficeLabel = "Microsoft 365 Word Reference" - } - "issue:pptx" = @{ - Source = "tests/Issue_Files/pptx" - LibreReference = "tests/Issue_Files/reference_pptx" - OfficeReference = "tests/Issue_Files/office_pptx" - LibreReferenceScript = "tests/MiniPdf.Benchmark/generate_reference_pdfs_pptx.py" - OfficeReferenceScript = "tests/MiniPdf.Benchmark/generate_office_pdfs_pptx.py" - SourceArgument = "--pptx-dir" - OfficeLabel = "Microsoft 365 PowerPoint Reference" - } -} - -$Config = $Defaults["$Suite`:$Format"] -if (-not $Config) { - throw "No Rust benchmark fixtures are configured for suite=$Suite format=$Format." -} -$SourceDir = Resolve-RepoPath $(if ($SourceDir) { $SourceDir } else { $Config.Source }) -$ReferenceDir = Resolve-RepoPath $(if ($ReferenceDir) { $ReferenceDir } else { $Config.OfficeReference }) -$AuxiliaryReferenceDir = Resolve-RepoPath $(if ($AuxiliaryReferenceDir) { $AuxiliaryReferenceDir } else { $Config.LibreReference }) -$ReferenceLabel = $Config.OfficeLabel -$AuxiliaryReferenceLabel = "LibreOffice" -if ($Engine -eq "libre") { - Write-Warning "-Engine libre is retained for compatibility. Microsoft 365 remains the primary scored reference; LibreOffice is auxiliary." -} -$IsFocusedRun = -not [string]::IsNullOrWhiteSpace($Filter) -or $MaxCases -gt 0 -$DefaultArtifactRoot = "artifacts/rust-benchmark/$Suite/$Format" -if ($IsFocusedRun) { - $FilterLabel = if ([string]::IsNullOrWhiteSpace($Filter)) { "all" } else { $Filter -replace '[^A-Za-z0-9._-]', '_' } - $CaseLabel = if ($MaxCases -gt 0) { "max-$MaxCases" } else { "all" } - $DefaultArtifactRoot = "artifacts/rust-benchmark/focused/$Suite/$Format/$FilterLabel-$CaseLabel" -} -$CandidateDir = Resolve-RepoPath $(if ($CandidateDir) { $CandidateDir } else { "$DefaultArtifactRoot/candidates" }) -$ReportDir = Resolve-RepoPath $(if ($ReportDir) { $ReportDir } else { "$DefaultArtifactRoot/report" }) - -$Cargo = Join-Path $env:USERPROFILE ".cargo/bin/cargo.exe" -$Python = Join-Path $RepoRoot ".venv/Scripts/python.exe" -if (-not (Test-Path $Cargo)) { $Cargo = (Get-Command cargo -ErrorAction Stop).Source } -if (-not (Test-Path $Python)) { $Python = (Get-Command python -ErrorAction Stop).Source } - -$CargoManifest = Join-Path $RepoRoot "minipdf-rs/Cargo.toml" -$OfficeReferenceScript = Resolve-RepoPath $Config.OfficeReferenceScript -$LibreReferenceScript = Resolve-RepoPath $Config.LibreReferenceScript -$CompareScript = Join-Path $RepoRoot "tests/MiniPdf.Benchmark/compare_pdfs.py" -$ComparisonManifest = Join-Path $ReportDir "comparison_manifest.json" -$CoverageManifest = Join-Path $ReportDir "benchmark_coverage.json" - -$SourceFiles = @(Get-ChildItem $SourceDir -File -Filter "*.$Format" | Where-Object { - -not $Filter -or $_.BaseName -like "*$Filter*" -} | Sort-Object Name) -if ($MaxCases -gt 0) { - $SourceFiles = @($SourceFiles | Select-Object -First $MaxCases) -} -if ($SourceFiles.Count -eq 0) { - throw "No .$Format files matched '$Filter' in $SourceDir" -} - -if (Test-Path -LiteralPath $ReportDir) { - Remove-Item -LiteralPath $ReportDir -Recurse -Force -} -New-Item -ItemType Directory -Force -Path $CandidateDir, $ReferenceDir, $AuxiliaryReferenceDir, $ReportDir | Out-Null - -$Cases = @($SourceFiles | ForEach-Object { - [pscustomobject]@{ - name = $_.BaseName - case_id = $_.BaseName - suite = $Suite - format = $Format - source_path = [System.IO.Path]::GetRelativePath($RepoRoot, $_.FullName).Replace("\", "/") - conversion_status = "pending" - conversion_exit_code = $null - candidate_exists = $false - reference_exists = $false - auxiliary_reference_exists = $false - } -}) - -[pscustomobject]@{ cases = $Cases } | ConvertTo-Json -Depth 5 | Set-Content $ComparisonManifest -Encoding UTF8 - -Write-Host "Rust benchmark matrix: suite=$Suite format=$Format primary=o365 auxiliary=libreoffice selected=$($Cases.Count)" -Write-Host "Shared fixtures only; C# xUnit assertions are not executed by this command." - -$Cli = Join-Path $RepoRoot "minipdf-rs/target/release/minipdf.exe" -if (-not $SkipCandidate) { - & $Cargo build --release --manifest-path $CargoManifest -p minipdf-cli - if ($LASTEXITCODE -ne 0) { throw "Rust CLI build failed." } - - for ($Index = 0; $Index -lt $SourceFiles.Count; $Index++) { - $SourceFile = $SourceFiles[$Index] - $OutputFile = Join-Path $CandidateDir ($SourceFile.BaseName + ".pdf") - if (Test-Path $OutputFile) { Remove-Item $OutputFile -Force } - - & $Cli $SourceFile.FullName -o $OutputFile - $ExitCode = $LASTEXITCODE - $Cases[$Index].conversion_exit_code = $ExitCode - $Cases[$Index].candidate_exists = Test-Path $OutputFile - $Cases[$Index].conversion_status = if ($ExitCode -eq 0 -and $Cases[$Index].candidate_exists) { "passed" } else { "failed" } - } -} else { - for ($Index = 0; $Index -lt $SourceFiles.Count; $Index++) { - $OutputFile = Join-Path $CandidateDir ($SourceFiles[$Index].BaseName + ".pdf") - $Cases[$Index].candidate_exists = Test-Path $OutputFile - $Cases[$Index].conversion_status = if ($Cases[$Index].candidate_exists) { "passed" } else { "failed" } - } -} - -if (-not $SkipReference) { - $ReferenceFilters = if ($MaxCases -gt 0) { @($SourceFiles.BaseName) } else { @($Filter) } - foreach ($ReferenceFilter in $ReferenceFilters) { - $Providers = @( - [pscustomobject]@{ Script = $OfficeReferenceScript; Directory = $ReferenceDir; Label = $ReferenceLabel }, - [pscustomobject]@{ Script = $LibreReferenceScript; Directory = $AuxiliaryReferenceDir; Label = $AuxiliaryReferenceLabel } - ) - foreach ($Provider in $Providers) { - $ReferenceArgs = @($Provider.Script, $Config.SourceArgument, $SourceDir, "--pdf-dir", $Provider.Directory) - if ($ReferenceFilter) { $ReferenceArgs += @("--filter", $ReferenceFilter) } - if ($ForceReference) { $ReferenceArgs += "--force" } - & $Python -X utf8 @ReferenceArgs - if ($LASTEXITCODE -ne 0) { throw "$($Provider.Label) generation failed." } - } - } -} - -foreach ($Case in $Cases) { - $Case.reference_exists = Test-Path (Join-Path $ReferenceDir ($Case.name + ".pdf")) - $Case.auxiliary_reference_exists = Test-Path (Join-Path $AuxiliaryReferenceDir ($Case.name + ".pdf")) -} - -$PassedConversions = @($Cases | Where-Object { $_.conversion_status -eq "passed" }).Count -$FailedConversions = $Cases.Count - $PassedConversions -$MissingReferences = @($Cases | Where-Object { -not $_.reference_exists }).Count -$MissingAuxiliaryReferences = @($Cases | Where-Object { -not $_.auxiliary_reference_exists }).Count -$Coverage = [pscustomobject]@{ - suite = $Suite - format = $Format - reference_engine = "o365" - reference_label = $ReferenceLabel - auxiliary_reference_engine = "libreoffice" - auxiliary_reference_label = "$AuxiliaryReferenceLabel (auxiliary)" - fixture_scope = "shared-on-disk-fixtures" - executes_dotnet_xunit = $false - max_compare_pages = $MaxComparePages - selected_cases = $Cases.Count - passed_conversions = $PassedConversions - failed_conversions = $FailedConversions - missing_references = $MissingReferences - missing_auxiliary_references = $MissingAuxiliaryReferences - comparison_completed = $false - comparison_results = 0 - average_score = $null - cases = $Cases -} -$Coverage | ConvertTo-Json -Depth 6 | Set-Content $CoverageManifest -Encoding UTF8 - -$CompareArgs = @( - $CompareScript, - "--minipdf-dir", $CandidateDir, - "--reference-dir", $ReferenceDir, - "--auxiliary-dir", $AuxiliaryReferenceDir, - "--report-dir", $ReportDir, - "--manifest", $ComparisonManifest, - "--report-scope", "rust-$Suite-$Format", - "--composite-images", - "--heatmaps", - "--candidate-label", "Rust MiniPdf", - "--reference-label", $ReferenceLabel, - "--auxiliary-label", $AuxiliaryReferenceLabel -) -if ($MaxComparePages -gt 0) { $CompareArgs += @("--max-pages", $MaxComparePages) } -& $Python -X utf8 @CompareArgs -if ($LASTEXITCODE -ne 0) { throw "PDF comparison failed." } - -$Results = @(Get-Content (Join-Path $ReportDir "comparison_report.json") -Raw | ConvertFrom-Json) -$BelowThreshold = @($Results | Where-Object { $null -eq $_.overall_score -or $_.overall_score -lt $MinimumScore }) -$Average = ($Results | Where-Object { $null -ne $_.overall_score } | Measure-Object -Property overall_score -Average).Average -$Coverage.comparison_completed = $true -$Coverage.comparison_results = $Results.Count -$Coverage.average_score = $Average -$Coverage | ConvertTo-Json -Depth 6 | Set-Content $CoverageManifest -Encoding UTF8 - -Write-Host "Coverage: selected=$($Cases.Count), converted=$PassedConversions, failed=$FailedConversions, missing O365 references=$MissingReferences, missing LibreOffice references=$MissingAuxiliaryReferences" -Write-Host "Visual results: compared=$($Results.Count), average score=$([math]::Round($Average, 4))" -Write-Host "Coverage manifest: $CoverageManifest" -Write-Host "Visual report: $(Join-Path $ReportDir 'comparison_report.md')" - -if ($FailedConversions -gt 0 -or $MissingReferences -gt 0 -or $MissingAuxiliaryReferences -gt 0 -or $BelowThreshold.Count -gt 0) { - $ThresholdFailures = ($BelowThreshold | ForEach-Object { "$($_.name)=$($_.overall_score)" }) -join ", " - throw "Rust benchmark failed: conversion failures=$FailedConversions, missing O365 references=$MissingReferences, missing LibreOffice references=$MissingAuxiliaryReferences, below $MinimumScore=[$ThresholdFailures]" -} \ No newline at end of file +param() +& (Join-Path $PSScriptRoot "Invoke-LanguageVisualBenchmark.ps1") -Language rust @args \ No newline at end of file diff --git a/tests/MiniPdf.Benchmark/README.md b/tests/MiniPdf.Benchmark/README.md index 26ac8b4f..d10a1c2b 100644 --- a/tests/MiniPdf.Benchmark/README.md +++ b/tests/MiniPdf.Benchmark/README.md @@ -1,6 +1,6 @@ # MiniPdf Self-Evolution Benchmark -Automatically compares PDFs generated by MiniPdf against LibreOffice (reference implementation), driving continuous rendering quality improvements. +Automatically compares PDFs generated by MiniPdf against Microsoft 365 and LibreOffice, driving continuous rendering quality improvements. ## Architecture Overview @@ -39,43 +39,48 @@ Automatically compares PDFs generated by MiniPdf against LibreOffice (reference ### Shared Cross-Language Corpus -All implementations use the Git-tracked fixture roots declared in -`shared-office-corpus.json`. The runner resolves the same XLSX, DOCX, and PPTX -files for every language, records each source SHA-256, and reuses one set of -LibreOffice reference PDFs. +All implementations use the same classic or issue fixture directory selected by +`-Suite` and `-Format`. Microsoft 365 is always the primary scored reference, +and LibreOffice is a required auxiliary reference displayed in the report. +`-Engine` is retained for compatibility and does not change the primary +reference. Each implementation has an independent entry point and isolated output under -`artifacts/benchmark//`: +`artifacts/-benchmark///`: ```powershell -.\scripts\Run-DotNet-VisualBenchmark.ps1 -Format all -.\scripts\Run-Rust-VisualBenchmark.ps1 -Format all -.\scripts\Run-Java-VisualBenchmark.ps1 -Format all -.\scripts\Run-Go-VisualBenchmark.ps1 -Format all -.\scripts\Run-Python-VisualBenchmark.ps1 -Format all -.\scripts\Run-Node-VisualBenchmark.ps1 -Format all +.\scripts\Run-DotNet-VisualBenchmark.ps1 -Suite classic -Format xlsx +.\scripts\Run-Rust-Benchmark.ps1 -Suite classic -Format xlsx +.\scripts\Run-Java-VisualBenchmark.ps1 -Suite issue -Format docx +.\scripts\Run-Go-VisualBenchmark.ps1 -Suite classic -Format docx +.\scripts\Run-Python-VisualBenchmark.ps1 -Suite issue -Format pptx +.\scripts\Run-Node-VisualBenchmark.ps1 -Suite issue -Format xlsx # Run all six implementations against the same selected cases. -.\scripts\Run-All-Language-VisualBenchmarks.ps1 -Format all +.\scripts\Run-All-Language-VisualBenchmarks.ps1 -Suite issue -Format xlsx -MaxCases 1 ``` -Use `-Filter`, `-MaxCasesPerFormat`, and `-MaxComparePages` for focused runs. -Use `-SkipReference` to reuse shared references and `-SkipBuild` to reuse an -existing language artifact. Each language receives its own resolved manifest, -coverage JSON, candidate PDFs, comparison JSON, Markdown report, images, and -heatmaps. +The default `MinimumScore` is `0.95`. Use `-Filter`, `-MaxCases`, and +`-MaxComparePages` for focused runs. Use `-SkipCandidate` to reuse existing +candidate PDFs and `-SkipReference` to reuse both existing reference sets. +The run fails when a candidate, Microsoft 365 reference, or LibreOffice +reference is missing, or when any score is below the threshold. Each language +receives its own coverage JSON, candidate PDFs, comparison JSON, Markdown +report, images, and heatmaps. ### Prerequisites ```bash -# 1. Python 3.10+ & dependencies -pip install openpyxl pymupdf +# 1. Python 3.10+ & dependencies (pywin32 is required for Microsoft 365 references) +pip install openpyxl pymupdf pywin32 -# 2. LibreOffice (free, used to generate reference PDFs) +# 2. Desktop Microsoft Excel/Word/PowerPoint (primary references) + +# 3. LibreOffice (free, used to generate auxiliary reference PDFs) # Windows: https://www.libreoffice.org/download/ # or: winget install LibreOffice -# 3. .NET 9 SDK +# 4. The toolchain required by the selected MiniPdf implementation ``` ### One-Click Execution From 1cfd83cd48d6866f55581e2b86cd609e8d2f7b2d Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 15:00:45 +0800 Subject: [PATCH 4/4] fix(rust): align centered VML rendering with O365 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register and prefer STKaiti for 华文楷体 content, keep punctuation on fonts that contain the glyph, and calibrate centered legacy VML image geometry against the Microsoft 365 reference. --- minipdf-rs/crates/minipdf-cli/src/main.rs | 2 +- minipdf-rs/crates/minipdf/src/pdf.rs | 4 + minipdf-rs/crates/minipdf/src/xlsx.rs | 96 +++++++++++++++++++---- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/minipdf-rs/crates/minipdf-cli/src/main.rs b/minipdf-rs/crates/minipdf-cli/src/main.rs index 43b502c0..79441d5f 100644 --- a/minipdf-rs/crates/minipdf-cli/src/main.rs +++ b/minipdf-rs/crates/minipdf-cli/src/main.rs @@ -200,7 +200,7 @@ fn register_office_cloud_fonts() -> minipdf::Result<()> { .map(|entry| entry.path().join("CloudFonts")) .filter(|path| path.is_dir()) { - for family in ["Grandview", "Grandview Display"] { + for family in ["Grandview", "Grandview Display", "STKaiti"] { let directory = cloud_root.join(family); let Ok(entries) = fs::read_dir(directory) else { continue; diff --git a/minipdf-rs/crates/minipdf/src/pdf.rs b/minipdf-rs/crates/minipdf/src/pdf.rs index fe0ee1e1..7c004c8e 100644 --- a/minipdf-rs/crates/minipdf/src/pdf.rs +++ b/minipdf-rs/crates/minipdf/src/pdf.rs @@ -688,6 +688,7 @@ fn split_font_runs( let font_index = if ch.is_whitespace() || ch.is_ascii_punctuation() || ch == '\u{fe0f}' { runs.last() .and_then(|run| run.font_index) + .filter(|index| font_supports(&fonts[*index], ch)) .or_else(|| select_font(fonts, ch, bold, italic, preferred_font)) } else { select_font(fonts, ch, bold, italic, preferred_font) @@ -763,6 +764,9 @@ fn font_preference( if name.starts_with(&preferred) { return 1; } + if preferred == "stkaiti" && name.starts_with("simkai") { + return 2; + } } let codepoint = ch as u32; let preferred = if matches!(codepoint, 0x0530..=0x058f) { diff --git a/minipdf-rs/crates/minipdf/src/xlsx.rs b/minipdf-rs/crates/minipdf/src/xlsx.rs index 75400318..27658609 100644 --- a/minipdf-rs/crates/minipdf/src/xlsx.rs +++ b/minipdf-rs/crates/minipdf/src/xlsx.rs @@ -30,6 +30,10 @@ const O365_PRINTER_FALLBACK_HORIZONTAL_SCALE: f32 = 1.0046; const O365_PRINTER_FALLBACK_LEFT_OFFSET: f32 = 0.48; const O365_PRINTER_FALLBACK_BORDER_SCALE: f32 = 1.8; const CENTERED_VML_HORIZONTAL_SCALE: f32 = 0.9754; +const CENTERED_VML_VERTICAL_SCALE: f32 = 1.005; +const CENTERED_VML_IMAGE_HORIZONTAL_SCALE: f32 = 1.04; +const CENTERED_VML_IMAGE_VERTICAL_SCALE: f32 = 1.017; +const CENTERED_VML_IMAGE_VERTICAL_OFFSET: f32 = 4.8; const SVG_FALLBACK_HORIZONTAL_SCALE: f32 = 0.972; const GROUP_DRAWING_TOP_OFFSET: f32 = 0.96; const ROW_HEIGHT: f32 = 15.0; @@ -313,6 +317,7 @@ struct SheetImage { data: SheetImageData, pixel_width: u16, pixel_height: u16, + legacy_vml: bool, col: usize, row: usize, col_offset: f32, @@ -975,6 +980,7 @@ fn read_sheet_images( data: SheetImageData::Jpeg(data), pixel_width, pixel_height, + legacy_vml: false, col: child_number(from, "col").unwrap_or(0), row: child_number(from, "row").unwrap_or(0), col_offset: child_number(from, "colOff").unwrap_or(0) as f32 / 12_700.0, @@ -1131,6 +1137,7 @@ fn read_legacy_drawing_images( data, pixel_width, pixel_height, + legacy_vml: true, col, row, col_offset, @@ -1382,6 +1389,7 @@ fn read_two_cell_shape( data: SheetImageData::Rgba(canvas.into_raw()), pixel_width, pixel_height, + legacy_vml: false, col: child_number(from, "col").unwrap_or(0), row: child_number(from, "row").unwrap_or(0), col_offset: child_number(from, "colOff").unwrap_or(0) as f32 / 12_700.0, @@ -1510,6 +1518,7 @@ fn read_two_cell_picture( data: SheetImageData::Rgba(rgba.into_raw()), pixel_width, pixel_height, + legacy_vml: false, col: child_number(from, "col").unwrap_or(0), row: child_number(from, "row").unwrap_or(0), col_offset: child_number(from, "colOff").unwrap_or(0) as f32 / 12_700.0, @@ -1692,6 +1701,7 @@ fn read_group_image( data: SheetImageData::Rgba(canvas.into_raw()), pixel_width, pixel_height, + legacy_vml: false, col: child_number(from, "col").unwrap_or(0), row: child_number(from, "row").unwrap_or(0), col_offset: child_number(from, "colOff").unwrap_or(0) as f32 / 12_700.0, @@ -2798,16 +2808,23 @@ fn excel_pdf_font_size(font_name: Option<&str>, size: f32) -> f32 { } fn xlsx_preferred_font(font_name: Option<&str>) -> Option<&'static str> { - match font_name.unwrap_or_default().to_ascii_lowercase().as_str() { + let normalized_name = font_name + .unwrap_or_default() + .chars() + .filter(|character| !character.is_whitespace()) + .flat_map(char::to_lowercase) + .collect::(); + match normalized_name.as_str() { "arial" => Some("arial"), "corbel" => Some("corbel"), - "franklin gothic medium" => Some("framd"), + "franklingothicmedium" => Some("framd"), "garamond" => Some("gara"), "grandview" => Some("grandview"), - "grandview display" => Some("grandviewdisplay"), - "kaiti" | "stkaiti" | "华文楷体" | "楷体" => Some("simkai"), - "palatino linotype" => Some("bookos"), - "tw cen mt" => Some("tcm_____"), + "grandviewdisplay" => Some("grandviewdisplay"), + "stkaiti" | "华文楷体" => Some("stkaiti"), + "kaiti" | "楷体" => Some("simkai"), + "palatinolinotype" => Some("bookos"), + "twcenmt" => Some("tcm_____"), "verdana" => Some("verdana"), _ => None, } @@ -3946,7 +3963,14 @@ fn render_sheet( } else { 1.0 }, - ) * if sheet.page_setup.o365_printer_fallback { + ) * if sheet.page_setup.horizontal_centered + && sheet.page_setup.vertical_centered + && sheet.page_setup.legacy_vml_drawing + { + CENTERED_VML_VERTICAL_SCALE + } else { + 1.0 + } * if sheet.page_setup.o365_printer_fallback { O365_PRINTER_FALLBACK_VERTICAL_SCALE } else { 1.0 @@ -4222,9 +4246,26 @@ fn render_sheet_columns( for (image, image_id) in sheet.images.iter().zip(image_ids).filter(|(image, _)| { image.foreground && image.col >= column_start && image.col < column_end }) { + let centered_legacy_vml = image.legacy_vml + && sheet.page_setup.horizontal_centered + && sheet.page_setup.vertical_centered; + let image_anchor_horizontal_scale = if centered_legacy_vml { + horizontal_geometry_scale / CENTERED_VML_HORIZONTAL_SCALE + } else { + horizontal_geometry_scale + }; + let image_horizontal_scale = if centered_legacy_vml { + CENTERED_VML_IMAGE_HORIZONTAL_SCALE + } else { + 1.0 + }; + let image_width = image.width * image_anchor_horizontal_scale * image_horizontal_scale; let x = content_left + column_widths[column_start..image.col].iter().sum::() - + image.col_offset * horizontal_geometry_scale; + * image_anchor_horizontal_scale + / horizontal_geometry_scale + + image.col_offset * image_anchor_horizontal_scale + - image.width * image_anchor_horizontal_scale * (image_horizontal_scale - 1.0) / 2.0; let rows_above = (0..image.row) .map(|row_index| { sheet @@ -4238,12 +4279,24 @@ fn render_sheet_columns( let top = page_size.height - margin_top - rows_above * row_scale - image.row_offset * row_scale + GROUP_DRAWING_TOP_OFFSET; + let image_vertical_scale = if centered_legacy_vml { + CENTERED_VML_IMAGE_VERTICAL_SCALE + } else { + 1.0 + }; + let image_height = image.height * row_scale * image_vertical_scale; + let image_vertical_offset = if centered_legacy_vml { + CENTERED_VML_IMAGE_VERTICAL_OFFSET + + image.height * row_scale * (image_vertical_scale - 1.0) / 2.0 + } else { + 0.0 + }; page.add_image( *image_id, x, - top - image.height * row_scale, - image.width * horizontal_geometry_scale, - image.height * row_scale, + top - image.height * row_scale - image_vertical_offset, + image_width, + image_height, ); } @@ -4616,6 +4669,8 @@ fn render_xlsx_row( let mut text_style = cell.style; let wrap_padding = if is_kaiti_slash_date(&cell.text, text_style) { 0.0 + } else if text_style.preferred_font == Some("stkaiti") { + 3.0 } else { 6.0 }; @@ -5859,9 +5914,21 @@ mod tests { #[test] fn maps_kaiti_family_names_to_installed_font() { - for name in ["华文楷体", "STKaiti", "KaiTi", "楷体"] { - assert_eq!(super::xlsx_preferred_font(Some(name)), Some("simkai")); - } + assert_eq!( + super::xlsx_preferred_font(Some("华文楷体")), + Some("stkaiti") + ); + assert_eq!( + super::xlsx_preferred_font(Some("华文 楷体")), + Some("stkaiti") + ); + assert_eq!( + super::xlsx_preferred_font(Some("华 文楷体")), + Some("stkaiti") + ); + assert_eq!(super::xlsx_preferred_font(Some("STKaiti")), Some("stkaiti")); + assert_eq!(super::xlsx_preferred_font(Some("KaiTi")), Some("simkai")); + assert_eq!(super::xlsx_preferred_font(Some("楷体")), Some("simkai")); } #[test] @@ -6036,6 +6103,7 @@ mod tests { data: SheetImageData::Jpeg(Vec::new()), pixel_width: 1, pixel_height: 1, + legacy_vml: false, col: 0, row: 3, col_offset: 0.0,