From 8c39c5eb64f3a5ae88dd38b1948913dba4174e6b Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Fri, 4 Sep 2026 09:30:11 +0800 Subject: [PATCH] fix(rust): improve DOCX VML and table fidelity Preserve requested CJK fonts and mixed table-cell run formatting, support legacy Word table layout semantics, and align VML textbox images with Microsoft 365 output. --- minipdf-rs/crates/minipdf-cli/src/main.rs | 3 + minipdf-rs/crates/minipdf/src/docx.rs | 1357 +++++++++++++++++---- minipdf-rs/crates/minipdf/src/pdf.rs | 18 +- 3 files changed, 1152 insertions(+), 226 deletions(-) diff --git a/minipdf-rs/crates/minipdf-cli/src/main.rs b/minipdf-rs/crates/minipdf-cli/src/main.rs index 153c0ccf..4a8a2976 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", + "simsun.ttc", "simhei.ttf", "malgunsl.ttf", "malgun.ttf", @@ -395,5 +396,7 @@ mod tests { for name in ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"] { assert!(names.iter().any(|candidate| candidate == name)); } + + assert!(names.iter().any(|candidate| candidate == "simsun.ttc")); } } diff --git a/minipdf-rs/crates/minipdf/src/docx.rs b/minipdf-rs/crates/minipdf/src/docx.rs index ef785761..ee014ed8 100644 --- a/minipdf-rs/crates/minipdf/src/docx.rs +++ b/minipdf-rs/crates/minipdf/src/docx.rs @@ -3,23 +3,24 @@ use std::io::{Cursor, Read}; use zip::ZipArchive; -use crate::pdf::{styled_text_width_with_font, PdfColor, PdfDocument, PdfTextStyle}; +use crate::pdf::{styled_text_width_with_font, PdfColor, PdfDocument}; use crate::{read_zip_text, ConversionOptions, PageSize, Result}; const PAGE_WIDTH: f32 = 595.28; const PAGE_HEIGHT: f32 = 841.89; const MARGIN: f32 = 54.0; const BODY_FONT_SIZE: f32 = 11.0; -const LINE_HEIGHT: f32 = 16.0; const TABLE_CELL_PADDING_HORIZONTAL: f32 = 5.4; -const TABLE_CELL_PADDING_VERTICAL: f32 = 1.0; const TABLE_BORDER_WIDTH: f32 = 0.5; +const VML_INLINE_IMAGE_TOP_LEADING: f32 = 2.0; #[derive(Debug, PartialEq)] struct DocxDocument { blocks: Vec, page_size: PageSize, margins: DocxMargins, + grid_line_pitch: f32, + compatibility_mode: u8, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -41,6 +42,7 @@ enum DocxBlock { #[derive(Debug, Clone, PartialEq)] struct DocxParagraph { runs: Vec, + floating_images: Vec, style_id: Option, contextual_spacing: bool, alignment: TextAlignment, @@ -49,18 +51,31 @@ struct DocxParagraph { spacing_before: f32, spacing_after: f32, line_spacing: f32, + line_spacing_explicit: bool, fill: Option, bottom_border: Option, } +#[derive(Debug, Clone, PartialEq)] +struct DocxFloatingImage { + image: DocxImage, + offset_x: f32, + offset_y: f32, + inset_left: f32, + inset_top: f32, + line_top_leading: f32, +} + #[derive(Debug, Clone, PartialEq)] struct DocxRun { text: String, font_size: f32, + font_name: Option, bold: bool, italic: bool, underline: bool, color: PdfColor, + highlight: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -69,6 +84,15 @@ struct DocxBorder { width: f32, space: f32, is_double: bool, + pattern: BorderPattern, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum BorderPattern { + Solid, + DotDash, + Dotted, + Dashed, } #[derive(Debug, Clone, Default)] @@ -76,6 +100,7 @@ struct DocxStyles { paragraph_defaults: ParagraphProperties, run_defaults: RunProperties, styles: HashMap, + theme_fonts: DocxThemeFonts, } #[derive(Debug, Clone, Default)] @@ -101,13 +126,28 @@ struct ParagraphProperties { #[derive(Debug, Clone, Default)] struct RunProperties { font_size: Option, + font_name: Option, + font_theme: Option, bold: Option, italic: Option, underline: Option, color: Option, + highlight: Option, } -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] +enum ThemeFontReference { + MajorEastAsia, + MinorEastAsia, +} + +#[derive(Debug, Clone, Default)] +struct DocxThemeFonts { + major_east_asia: Option, + minor_east_asia: Option, +} + +#[derive(Debug, Clone, PartialEq)] struct DocxImage { data: DocxImageData, width: f32, @@ -115,7 +155,7 @@ struct DocxImage { alignment: TextAlignment, } -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] enum DocxImageData { Jpeg { data: Vec, @@ -133,24 +173,42 @@ enum DocxImageData { struct DocxTable { column_widths: Vec, rows: Vec, + alignment: TextAlignment, + cell_margin_left: f32, + cell_margin_right: f32, + cell_margin_top: f32, + cell_margin_bottom: f32, + legacy_cjk_metrics: bool, } #[derive(Debug, PartialEq)] struct DocxTableRow { cells: Vec, + height: Option, } #[derive(Debug, PartialEq)] struct DocxTableCell { - text: String, + runs: Vec, images: Vec, width: Option, + spacing_before: f32, + spacing_after: f32, + line_spacing: f32, fill: Option, - font_size: f32, - bold: bool, - italic: bool, - color: PdfColor, alignment: TextAlignment, + vertical_alignment: VerticalAlignment, + grid_span: usize, + vertical_merge: VerticalMerge, + borders: DocxCellBorders, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +struct DocxCellBorders { + top: Option, + right: Option, + bottom: Option, + left: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -160,6 +218,20 @@ enum TextAlignment { Right, } +#[derive(Debug, Clone, Copy, PartialEq)] +enum VerticalAlignment { + Top, + Center, + Bottom, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum VerticalMerge { + None, + Restart, + Continue, +} + pub(crate) fn convert_docx_bytes(input: &[u8], options: &ConversionOptions) -> Result> { let document = read_docx_document(input)?; let mut doc = PdfDocument::new(); @@ -175,12 +247,13 @@ fn read_docx_document(input: &[u8]) -> Result { }; let styles = read_styles(&mut archive)?; let relationships = read_document_relationships(&mut archive)?; + let compatibility_mode = read_compatibility_mode(&mut archive)?; let xml = roxmltree::Document::parse(&document_xml)?; let Some(body) = xml.descendants().find(|node| node.has_tag_name("body")) else { return Ok(empty_docx_document()); }; - let (page_size, margins) = read_page_layout(body); + let (page_size, margins, grid_line_pitch) = read_page_layout(body); let mut blocks = Vec::new(); for node in body.children().filter(|node| node.is_element()) { @@ -189,8 +262,10 @@ fn read_docx_document(input: &[u8]) -> Result { } else if node.has_tag_name("tbl") { blocks.push(DocxBlock::Table(read_table( node, + &styles, &relationships, &mut archive, + compatibility_mode <= 12, )?)); } } @@ -205,9 +280,27 @@ fn read_docx_document(input: &[u8]) -> Result { blocks, page_size, margins, + grid_line_pitch, + compatibility_mode, }) } +fn read_compatibility_mode(archive: &mut ZipArchive>) -> Result { + let Some(settings_xml) = read_zip_text(archive, "word/settings.xml")? else { + return Ok(15); + }; + let xml = roxmltree::Document::parse(&settings_xml)?; + Ok(xml + .descendants() + .find(|node| { + node.has_tag_name("compatSetting") + && attribute_by_local_name(*node, "name") == Some("compatibilityMode") + }) + .and_then(|node| attribute_by_local_name(node, "val")) + .and_then(|value| value.parse().ok()) + .unwrap_or(15)) +} + fn empty_docx_document() -> DocxDocument { DocxDocument { blocks: vec![DocxBlock::Paragraph(plain_paragraph( @@ -223,6 +316,8 @@ fn empty_docx_document() -> DocxDocument { bottom: MARGIN, left: MARGIN, }, + grid_line_pitch: 0.0, + compatibility_mode: 15, } } @@ -231,25 +326,29 @@ fn plain_paragraph(text: String) -> DocxParagraph { runs: vec![DocxRun { text, font_size: BODY_FONT_SIZE, + font_name: None, bold: false, italic: false, underline: false, color: PdfColor::BLACK, + highlight: None, }], + floating_images: Vec::new(), style_id: None, contextual_spacing: false, alignment: TextAlignment::Left, indent_left: 0.0, indent_right: 0.0, spacing_before: 0.0, - spacing_after: LINE_HEIGHT * 0.35, + spacing_after: 0.0, line_spacing: 1.15, + line_spacing_explicit: false, fill: None, bottom_border: None, } } -fn read_page_layout(body: roxmltree::Node<'_, '_>) -> (PageSize, DocxMargins) { +fn read_page_layout(body: roxmltree::Node<'_, '_>) -> (PageSize, DocxMargins, f32) { let section = body.children().find(|node| node.has_tag_name("sectPr")); let page_size_node = section.and_then(|node| child(node, "pgSz")); let margin_node = section.and_then(|node| child(node, "pgMar")); @@ -263,12 +362,26 @@ fn read_page_layout(body: roxmltree::Node<'_, '_>) -> (PageSize, DocxMargins) { bottom: twips_attribute(margin_node, "bottom").unwrap_or(MARGIN), left: twips_attribute(margin_node, "left").unwrap_or(MARGIN), }; - (page_size, margins) + let grid_line_pitch = section + .and_then(|node| child(node, "docGrid")) + .filter(|node| { + matches!( + attribute_by_local_name(*node, "type"), + Some("lines" | "linesAndChars" | "snapToChars") + ) + }) + .and_then(|node| twips_attribute(Some(node), "linePitch")) + .unwrap_or(0.0); + (page_size, margins, grid_line_pitch) } fn read_styles(archive: &mut ZipArchive>) -> Result { + let theme_fonts = read_theme_fonts(archive)?; let Some(styles_xml) = read_zip_text(archive, "word/styles.xml")? else { - return Ok(DocxStyles::default()); + return Ok(DocxStyles { + theme_fonts, + ..DocxStyles::default() + }); }; let xml = roxmltree::Document::parse(&styles_xml)?; let defaults = xml @@ -307,9 +420,44 @@ fn read_styles(archive: &mut ZipArchive>) -> Result { paragraph_defaults, run_defaults, styles, + theme_fonts, }) } +fn read_theme_fonts(archive: &mut ZipArchive>) -> Result { + let Some(theme_xml) = read_zip_text(archive, "word/theme/theme1.xml")? else { + return Ok(DocxThemeFonts::default()); + }; + let xml = roxmltree::Document::parse(&theme_xml)?; + let font_scheme = xml + .descendants() + .find(|node| node.has_tag_name("fontScheme")); + Ok(DocxThemeFonts { + major_east_asia: font_scheme + .and_then(|node| child(node, "majorFont")) + .and_then(theme_east_asia_font), + minor_east_asia: font_scheme + .and_then(|node| child(node, "minorFont")) + .and_then(theme_east_asia_font), + }) +} + +fn theme_east_asia_font(group: roxmltree::Node<'_, '_>) -> Option { + child(group, "ea") + .and_then(|node| attribute_by_local_name(node, "typeface")) + .filter(|name| !name.is_empty()) + .or_else(|| { + group + .children() + .find(|node| { + node.has_tag_name("font") + && attribute_by_local_name(*node, "script") == Some("Hans") + }) + .and_then(|node| attribute_by_local_name(node, "typeface")) + }) + .map(str::to_owned) +} + fn read_paragraph_properties(properties: roxmltree::Node<'_, '_>) -> ParagraphProperties { let spacing = child(properties, "spacing"); let indentation = child(properties, "ind"); @@ -343,16 +491,59 @@ fn read_paragraph_properties(properties: roxmltree::Node<'_, '_>) -> ParagraphPr } fn read_run_properties(properties: roxmltree::Node<'_, '_>) -> RunProperties { + let fonts = child(properties, "rFonts"); RunProperties { font_size: child(properties, "sz") .and_then(|node| numeric_attribute(node, "val")) .map(|half_points| half_points / 2.0), + font_name: fonts + .and_then(|node| { + attribute_by_local_name(node, "eastAsia") + .or_else(|| attribute_by_local_name(node, "ascii")) + .or_else(|| attribute_by_local_name(node, "hAnsi")) + }) + .map(str::to_owned), + font_theme: fonts + .and_then(|node| { + attribute_by_local_name(node, "eastAsiaTheme") + .or_else(|| attribute_by_local_name(node, "asciiTheme")) + .or_else(|| attribute_by_local_name(node, "hAnsiTheme")) + }) + .and_then(|value| match value { + "majorEastAsia" => Some(ThemeFontReference::MajorEastAsia), + "minorEastAsia" => Some(ThemeFontReference::MinorEastAsia), + _ => None, + }), bold: child(properties, "b").map(property_enabled), italic: child(properties, "i").map(property_enabled), underline: child(properties, "u").map(property_enabled), color: child(properties, "color") .and_then(|node| attribute_by_local_name(node, "val")) .and_then(parse_hex_color), + highlight: child(properties, "highlight") + .and_then(|node| attribute_by_local_name(node, "val")) + .and_then(parse_highlight_color), + } +} + +fn parse_highlight_color(value: &str) -> Option { + match value { + "yellow" => Some(PdfColor::new(1.0, 1.0, 0.0)), + "green" => Some(PdfColor::new(0.0, 1.0, 0.0)), + "cyan" => Some(PdfColor::new(0.0, 1.0, 1.0)), + "magenta" => Some(PdfColor::new(1.0, 0.0, 1.0)), + "blue" => Some(PdfColor::new(0.0, 0.0, 1.0)), + "red" => Some(PdfColor::new(1.0, 0.0, 0.0)), + "darkBlue" => Some(PdfColor::new(0.0, 0.0, 0.5)), + "darkCyan" => Some(PdfColor::new(0.0, 0.5, 0.5)), + "darkGreen" => Some(PdfColor::new(0.0, 0.5, 0.0)), + "darkMagenta" => Some(PdfColor::new(0.5, 0.0, 0.5)), + "darkRed" => Some(PdfColor::new(0.5, 0.0, 0.0)), + "darkYellow" => Some(PdfColor::new(0.5, 0.5, 0.0)), + "darkGray" => Some(PdfColor::new(0.5, 0.5, 0.5)), + "lightGray" => Some(PdfColor::new(0.75, 0.75, 0.75)), + "black" => Some(PdfColor::BLACK), + _ => None, } } @@ -368,9 +559,25 @@ fn read_border(border: roxmltree::Node<'_, '_>) -> Option { width: numeric_attribute(border, "sz").unwrap_or(4.0) / 8.0, space: numeric_attribute(border, "space").unwrap_or(0.0), is_double: style == "double", + pattern: match style { + "dotDash" | "dashDotStroked" => BorderPattern::DotDash, + "dotted" => BorderPattern::Dotted, + "dashed" | "dashSmallGap" => BorderPattern::Dashed, + _ => BorderPattern::Solid, + }, }) } +fn default_table_border() -> DocxBorder { + DocxBorder { + color: PdfColor::BLACK, + width: TABLE_BORDER_WIDTH, + space: 0.0, + is_double: false, + pattern: BorderPattern::Solid, + } +} + fn parse_alignment(value: &str) -> TextAlignment { match value { "center" => TextAlignment::Center, @@ -420,6 +627,14 @@ fn merge_run_properties(target: &mut RunProperties, source: &RunProperties) { if source.font_size.is_some() { target.font_size = source.font_size; } + if source.font_name.is_some() { + target.font_name.clone_from(&source.font_name); + target.font_theme = None; + } + if source.font_theme.is_some() { + target.font_theme = source.font_theme; + target.font_name = None; + } if source.bold.is_some() { target.bold = source.bold; } @@ -432,6 +647,9 @@ fn merge_run_properties(target: &mut RunProperties, source: &RunProperties) { if source.color.is_some() { target.color = source.color; } + if source.highlight.is_some() { + target.highlight = source.highlight; + } } fn merge_style_chain( @@ -490,12 +708,23 @@ fn read_paragraph( merge_run_properties(&mut base_run_properties, &read_run_properties(properties)); } + let floating_images = read_vml_floating_images(paragraph, relationships, archive)?; + let mut runs = Vec::new(); let mut emitted_non_text = false; for run_node in paragraph .descendants() .filter(|node| node.has_tag_name("r")) { + if run_node + .ancestors() + .any(|node| node.has_tag_name("txbxContent")) + || run_node + .descendants() + .any(|node| node.has_tag_name("txbxContent")) + { + continue; + } if let Some(drawing) = run_node .descendants() .find(|node| node.has_tag_name("drawing")) @@ -523,7 +752,11 @@ fn read_paragraph( } else if node.has_tag_name("br") { if attribute_by_local_name(node, "type") == Some("page") { if !text.is_empty() { - runs.push(create_run(std::mem::take(&mut text), &run_properties)); + runs.push(create_run( + std::mem::take(&mut text), + &run_properties, + &styles.theme_fonts, + )); } push_paragraph_runs(blocks, &mut runs, style_id, ¶graph_properties); blocks.push(DocxBlock::PageBreak); @@ -534,27 +767,138 @@ fn read_paragraph( } } if !text.is_empty() { - runs.push(create_run(text, &run_properties)); + runs.push(create_run(text, &run_properties, &styles.theme_fonts)); } } if !runs.is_empty() || !emitted_non_text { - blocks.push(DocxBlock::Paragraph(create_paragraph( - std::mem::take(&mut runs), - style_id, - ¶graph_properties, - ))); + let mut output = + create_paragraph(std::mem::take(&mut runs), style_id, ¶graph_properties); + output.floating_images = floating_images; + blocks.push(DocxBlock::Paragraph(output)); } Ok(()) } -fn create_run(text: String, properties: &RunProperties) -> DocxRun { +fn read_vml_floating_images( + paragraph: roxmltree::Node<'_, '_>, + relationships: &HashMap, + archive: &mut ZipArchive>, +) -> Result> { + let mut images = Vec::new(); + for shape in paragraph + .descendants() + .filter(|node| node.has_tag_name("shape")) + { + let Some(style) = shape.attribute("style") else { + continue; + }; + if !vml_style_value(style, "position") + .is_some_and(|value| value.eq_ignore_ascii_case("absolute")) + { + continue; + } + let Some(textbox) = shape + .descendants() + .find(|node| node.has_tag_name("txbxContent")) + else { + continue; + }; + let offset_x = vml_style_point(style, "margin-left").unwrap_or(0.0); + let offset_y = vml_style_point(style, "margin-top").unwrap_or(0.0); + let (inset_left, inset_top) = vml_textbox_insets(shape); + for drawing in textbox + .descendants() + .filter(|node| node.has_tag_name("drawing")) + { + let image_paragraph = drawing + .ancestors() + .find(|node| node.has_tag_name("p")) + .unwrap_or(paragraph); + if let Some(image) = read_image(drawing, image_paragraph, relationships, archive)? { + images.push(DocxFloatingImage { + image, + offset_x, + offset_y, + inset_left, + inset_top, + line_top_leading: VML_INLINE_IMAGE_TOP_LEADING, + }); + } + } + } + Ok(images) +} + +fn vml_style_value<'a>(style: &'a str, name: &str) -> Option<&'a str> { + style.split(';').find_map(|declaration| { + let (property, value) = declaration.split_once(':')?; + property + .trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim()) + }) +} + +fn vml_style_point(style: &str, name: &str) -> Option { + parse_vml_length(vml_style_value(style, name)?) +} + +fn parse_vml_length(value: &str) -> Option { + let value = value.trim(); + if let Some(points) = value.strip_suffix("pt") { + points.trim().parse().ok() + } else if let Some(inches) = value.strip_suffix("in") { + inches + .trim() + .parse::() + .ok() + .map(|length| length * 72.0) + } else if let Some(pixels) = value.strip_suffix("px") { + pixels + .trim() + .parse::() + .ok() + .map(|length| length * 0.75) + } else { + value.parse().ok() + } +} + +fn vml_textbox_insets(shape: roxmltree::Node<'_, '_>) -> (f32, f32) { + let inset = shape + .descendants() + .find(|node| node.has_tag_name("textbox")) + .and_then(|node| node.attribute("inset")); + let Some(inset) = inset else { + return (7.2, 3.6); + }; + let values: Vec<_> = inset + .split(',') + .filter_map(|value| parse_vml_length(value.trim())) + .collect(); + ( + values.first().copied().unwrap_or(7.2), + values.get(1).copied().unwrap_or(3.6), + ) +} + +fn create_run(text: String, properties: &RunProperties, theme_fonts: &DocxThemeFonts) -> DocxRun { DocxRun { text, font_size: properties.font_size.unwrap_or(BODY_FONT_SIZE), + font_name: properties + .font_name + .clone() + .or_else(|| match properties.font_theme { + Some(ThemeFontReference::MajorEastAsia) => theme_fonts.major_east_asia.clone(), + Some(ThemeFontReference::MinorEastAsia) => theme_fonts.minor_east_asia.clone(), + None => None, + }), bold: properties.bold.unwrap_or(false), italic: properties.italic.unwrap_or(false), underline: properties.underline.unwrap_or(false), color: properties.color.unwrap_or(PdfColor::BLACK), + highlight: properties.highlight, } } @@ -566,10 +910,11 @@ fn create_paragraph( let spacing_after = if runs.is_empty() { 0.0 } else { - properties.spacing_after.unwrap_or(LINE_HEIGHT * 0.35) + properties.spacing_after.unwrap_or(0.0) }; DocxParagraph { runs, + floating_images: Vec::new(), style_id: style_id.map(str::to_owned), contextual_spacing: properties.contextual_spacing.unwrap_or(false), alignment: properties.alignment.unwrap_or(TextAlignment::Left), @@ -578,6 +923,7 @@ fn create_paragraph( spacing_before: properties.spacing_before.unwrap_or(0.0), spacing_after, line_spacing: properties.line_spacing.unwrap_or(1.15), + line_spacing_explicit: properties.line_spacing.is_some(), fill: properties.fill, bottom_border: properties.bottom_border, } @@ -756,9 +1102,21 @@ fn jpeg_dimensions(data: &[u8]) -> Option<(u16, u16)> { fn read_table( table: roxmltree::Node<'_, '_>, + styles: &DocxStyles, relationships: &HashMap, archive: &mut ZipArchive>, + legacy_cjk_metrics: bool, ) -> Result { + let properties = child(table, "tblPr"); + let cell_margins = properties.and_then(|node| child(node, "tblCellMar")); + let style_id = properties + .and_then(|node| child(node, "tblStyle")) + .and_then(|node| attribute_by_local_name(node, "val")); + let style_has_grid = style_id == Some("af2"); + let use_style_borders = style_has_grid + && properties + .and_then(|node| child(node, "tblBorders")) + .is_none(); let column_widths = child(table, "tblGrid") .map(|grid| { grid.children() @@ -769,40 +1127,72 @@ fn read_table( .unwrap_or_default(); let mut rows = Vec::new(); for row in table.children().filter(|node| node.has_tag_name("tr")) { + let height = child(row, "trPr") + .and_then(|node| child(node, "trHeight")) + .and_then(|node| twips_attribute(Some(node), "val")); let mut cells = Vec::new(); for cell in row.children().filter(|node| node.has_tag_name("tc")) { - cells.push(read_table_cell(cell, relationships, archive)?); + cells.push(read_table_cell( + cell, + styles, + relationships, + archive, + use_style_borders, + )?); } - rows.push(DocxTableRow { cells }); + rows.push(DocxTableRow { cells, height }); } Ok(DocxTable { column_widths, rows, + alignment: properties + .and_then(|node| child(node, "jc")) + .and_then(|node| attribute_by_local_name(node, "val")) + .map(parse_alignment) + .unwrap_or(TextAlignment::Left), + cell_margin_left: table_margin(cell_margins, "left", TABLE_CELL_PADDING_HORIZONTAL), + cell_margin_right: table_margin(cell_margins, "right", TABLE_CELL_PADDING_HORIZONTAL), + cell_margin_top: table_margin(cell_margins, "top", 0.0), + cell_margin_bottom: table_margin(cell_margins, "bottom", 0.0), + legacy_cjk_metrics, }) } +fn table_margin(margins: Option>, side: &str, default: f32) -> f32 { + margins + .and_then(|node| child(node, side)) + .and_then(|node| twips_attribute(Some(node), "w")) + .unwrap_or(default) +} + fn read_table_cell( cell: roxmltree::Node<'_, '_>, + styles: &DocxStyles, relationships: &HashMap, archive: &mut ZipArchive>, + use_style_borders: bool, ) -> Result { let properties = child(cell, "tcPr"); let paragraphs: Vec<_> = cell .children() .filter(|node| node.has_tag_name("p")) .collect(); - let text = paragraphs - .iter() - .map(|paragraph| paragraph_text(*paragraph)) - .filter(|text| !text.is_empty()) - .collect::>() - .join("\n"); + let mut runs: Vec = Vec::new(); + for paragraph in ¶graphs { + let paragraph_runs = read_table_cell_runs(*paragraph, styles); + if paragraph_runs.is_empty() { + continue; + } + if let Some(previous) = runs.last_mut() { + previous.text.push('\n'); + } + runs.extend(paragraph_runs); + } let first_paragraph = paragraphs.first().copied(); - let first_run_properties = first_paragraph.and_then(|paragraph| { - paragraph - .descendants() - .find(|node| node.has_tag_name("rPr")) - }); + let first_paragraph_properties = first_paragraph + .and_then(|paragraph| child(paragraph, "pPr")) + .map(read_paragraph_properties) + .unwrap_or_default(); let mut images = Vec::new(); for paragraph in ¶graphs { for drawing in paragraph @@ -816,27 +1206,18 @@ fn read_table_cell( } Ok(DocxTableCell { - text, + runs, images, width: properties .and_then(|node| child(node, "tcW")) .and_then(|node| twips_attribute(Some(node), "w")), + spacing_before: first_paragraph_properties.spacing_before.unwrap_or(0.0), + spacing_after: first_paragraph_properties.spacing_after.unwrap_or(0.0), + line_spacing: first_paragraph_properties.line_spacing.unwrap_or(1.0), fill: properties .and_then(|node| child(node, "shd")) .and_then(|node| attribute_by_local_name(node, "fill")) .and_then(parse_hex_color), - font_size: first_run_properties - .and_then(|node| child(node, "sz")) - .and_then(|node| numeric_attribute(node, "val")) - .map(|half_points| half_points / 2.0) - .unwrap_or(BODY_FONT_SIZE), - bold: first_run_properties.is_some_and(|node| enabled_property(node, "b")), - italic: first_run_properties.is_some_and(|node| enabled_property(node, "i")), - color: first_run_properties - .and_then(|node| child(node, "color")) - .and_then(|node| attribute_by_local_name(node, "val")) - .and_then(parse_hex_color) - .unwrap_or(PdfColor::BLACK), alignment: first_paragraph .and_then(|node| node.descendants().find(|child| child.has_tag_name("jc"))) .and_then(|node| attribute_by_local_name(node, "val")) @@ -846,9 +1227,84 @@ fn read_table_cell( _ => TextAlignment::Left, }) .unwrap_or(TextAlignment::Left), + vertical_alignment: properties + .and_then(|node| child(node, "vAlign")) + .and_then(|node| attribute_by_local_name(node, "val")) + .map(|value| match value { + "center" => VerticalAlignment::Center, + "bottom" => VerticalAlignment::Bottom, + _ => VerticalAlignment::Top, + }) + .unwrap_or(VerticalAlignment::Top), + grid_span: properties + .and_then(|node| child(node, "gridSpan")) + .and_then(|node| numeric_attribute(node, "val")) + .map(|value| value.max(1.0) as usize) + .unwrap_or(1), + vertical_merge: properties + .and_then(|node| child(node, "vMerge")) + .map(|node| match attribute_by_local_name(node, "val") { + Some("restart") => VerticalMerge::Restart, + _ => VerticalMerge::Continue, + }) + .unwrap_or(VerticalMerge::None), + borders: DocxCellBorders { + top: read_cell_border(properties, "top", use_style_borders), + right: read_cell_border(properties, "right", use_style_borders), + bottom: read_cell_border(properties, "bottom", use_style_borders), + left: read_cell_border(properties, "left", use_style_borders), + }, }) } +fn read_table_cell_runs(paragraph: roxmltree::Node<'_, '_>, styles: &DocxStyles) -> Vec { + let style_id = child(paragraph, "pPr") + .and_then(|node| child(node, "pStyle")) + .and_then(|node| attribute_by_local_name(node, "val")); + let mut paragraph_properties = styles.paragraph_defaults.clone(); + let mut base_run_properties = styles.run_defaults.clone(); + if let Some(style_id) = style_id { + merge_style_chain( + styles, + style_id, + &mut paragraph_properties, + &mut base_run_properties, + 0, + ); + } + if let Some(properties) = child(paragraph, "pPr").and_then(|node| child(node, "rPr")) { + merge_run_properties(&mut base_run_properties, &read_run_properties(properties)); + } + + paragraph + .descendants() + .filter(|node| node.has_tag_name("r")) + .filter_map(|run_node| { + let mut properties = base_run_properties.clone(); + if let Some(direct) = child(run_node, "rPr") { + merge_run_properties(&mut properties, &read_run_properties(direct)); + } + let text = paragraph_text(run_node); + (!text.is_empty()).then(|| create_run(text, &properties, &styles.theme_fonts)) + }) + .collect() +} + +fn read_cell_border( + properties: Option>, + side: &str, + use_style_border: bool, +) -> Option { + let border = properties + .and_then(|node| child(node, "tcBorders")) + .and_then(|node| child(node, side)); + match border { + Some(border) => read_border(border), + None if use_style_border => Some(default_table_border()), + None => None, + } +} + fn paragraph_text(paragraph: roxmltree::Node<'_, '_>) -> String { let mut text = String::new(); for node in paragraph.descendants() { @@ -894,15 +1350,6 @@ fn numeric_attribute(node: roxmltree::Node<'_, '_>, name: &str) -> Option { attribute_by_local_name(node, name)?.parse().ok() } -fn enabled_property(properties: roxmltree::Node<'_, '_>, name: &str) -> bool { - child(properties, name).is_some_and(|node| { - !matches!( - attribute_by_local_name(node, "val"), - Some("0" | "false" | "off") - ) - }) -} - fn parse_hex_color(value: &str) -> Option { if value.len() != 6 || value.eq_ignore_ascii_case("auto") { return None; @@ -963,7 +1410,9 @@ fn render_docx(doc: &mut PdfDocument, document: &DocxDocument, options: &Convers previous.spacing_after } }); - let spacing_before = if previous_paragraph.is_some() { + let spacing_before = if document.compatibility_mode <= 12 { + paragraph.spacing_before + } else if previous_paragraph.is_some() { (paragraph.spacing_before - previous_spacing_after).max(0.0) } else { paragraph.spacing_before @@ -977,10 +1426,17 @@ fn render_docx(doc: &mut PdfDocument, document: &DocxDocument, options: &Convers } else { paragraph.spacing_after }; + let previous_grid_ascent = previous_paragraph + .map(|previous| paragraph_line_metrics(previous, document.grid_line_pitch).2); render_paragraph( doc, paragraph, - (spacing_before, spacing_after), + ( + spacing_before, + spacing_after, + document.grid_line_pitch, + previous_grid_ascent, + ), page_size, margins, &mut page_index, @@ -1012,19 +1468,14 @@ fn render_docx(doc: &mut PdfDocument, document: &DocxDocument, options: &Convers fn render_paragraph( doc: &mut PdfDocument, paragraph: &DocxParagraph, - spacing: (f32, f32), + layout: (f32, f32, f32, Option), page_size: PageSize, margins: DocxMargins, page_index: &mut usize, y: &mut f32, ) { - let (spacing_before, spacing_after) = spacing; - let font_size = paragraph - .runs - .iter() - .map(|run| run.font_size) - .fold(BODY_FONT_SIZE, f32::max); - let line_height = font_size * 1.18 * paragraph.line_spacing; + let (spacing_before, spacing_after, grid_line_pitch, previous_grid_ascent) = layout; + let (_, line_height, baseline_offset) = paragraph_line_metrics(paragraph, grid_line_pitch); let available_width = (page_size.width - margins.left - margins.right @@ -1042,6 +1493,11 @@ fn render_paragraph( top = page_size.height - margins.top; } top -= spacing_before; + if grid_line_pitch > 0.0 { + if let Some(previous_ascent) = previous_grid_ascent.filter(|ascent| *ascent > 0.0) { + top -= baseline_offset - previous_ascent; + } + } let left = margins.left + paragraph.indent_left; let bottom = top - content_height; if let Some(fill) = paragraph.fill { @@ -1057,21 +1513,19 @@ fn render_paragraph( TextAlignment::Center => left + (available_width - line_width) / 2.0, TextAlignment::Right => left + available_width - line_width, }; - let baseline = top - font_size - line_index as f32 * line_height; + let baseline = top - baseline_offset - line_index as f32 * line_height; for run in line { let width = run_width(run, &run.text); let page = doc.page_mut(*page_index).expect("page index is valid"); - page.add_styled_text( + page.add_styled_text_with_font( &run.text, x, baseline, run.font_size, - PdfTextStyle { - color: run.color, - bold: run.bold, - italic: run.italic, - preferred_font: None, - }, + run.color, + run.bold, + run.italic, + run.font_name.as_deref(), ); if run.underline && !run.text.trim().is_empty() { page.add_line(x, baseline - 1.5, x + width, baseline - 1.5, run.color, 0.5); @@ -1080,31 +1534,112 @@ fn render_paragraph( } } + for floating in ¶graph.floating_images { + let image_id = register_image(doc, &floating.image); + doc.page_mut(*page_index) + .expect("page index is valid") + .add_image( + image_id, + margins.left + floating.offset_x + floating.inset_left, + top - floating.offset_y + - floating.inset_top + - floating.line_top_leading + - floating.image.height, + floating.image.width, + floating.image.height, + ); + } + if let Some(border) = paragraph.bottom_border { let border_y = bottom - border.space; let page = doc.page_mut(*page_index).expect("page index is valid"); - page.add_line( - left, - border_y, - left + available_width, - border_y, - border.color, - border.width, + draw_border( + page, + (left, border_y), + (left + available_width, border_y), + border, ); if border.is_double { - page.add_line( - left, - border_y - border.width * 2.0, - left + available_width, - border_y - border.width * 2.0, - border.color, - border.width, + draw_border( + page, + (left, border_y - border.width * 2.0), + (left + available_width, border_y - border.width * 2.0), + border, ); } } *y = top - content_height - spacing_after - BODY_FONT_SIZE; } +fn paragraph_line_metrics(paragraph: &DocxParagraph, grid_line_pitch: f32) -> (f32, f32, f32) { + let font_size = paragraph + .runs + .iter() + .map(|run| run.font_size) + .fold(BODY_FONT_SIZE, f32::max); + let mut line_height = font_size * 1.18 * paragraph.line_spacing; + if grid_line_pitch <= 0.0 { + return (font_size, line_height, font_size); + } + + if paragraph.line_spacing_explicit { + line_height = line_height.max(grid_line_pitch * paragraph.line_spacing); + let text_length: usize = paragraph + .runs + .iter() + .map(|run| run.text.chars().count()) + .sum(); + let is_centered_cjk_heading = paragraph.alignment == TextAlignment::Center + && font_size >= 14.0 + && text_length <= 12 + && paragraph + .runs + .iter() + .any(|run| run.text.chars().any(is_cjk_character)); + if is_centered_cjk_heading { + line_height = + line_height.max((font_size * 1.29 / grid_line_pitch).ceil() * grid_line_pitch); + } + } else if font_size <= grid_line_pitch { + line_height = grid_line_pitch; + } else { + line_height = line_height.max(grid_line_pitch); + } + + let font_name = paragraph + .runs + .iter() + .find_map(|run| run.font_name.as_deref()); + let baseline_offset = if font_name.is_some_and(is_tall_cjk_font_name) { + (line_height - font_size) / 2.0 + font_size * 0.86 + } else { + (line_height + font_size) / 2.0 + }; + (font_size, line_height, baseline_offset) +} + +fn is_cjk_character(ch: char) -> bool { + matches!(ch as u32, 0x2e80..=0x9fff | 0xf900..=0xfaff) +} + +fn is_tall_cjk_font_name(font_name: &str) -> bool { + let normalized = font_name.trim().to_ascii_lowercase(); + [ + "kaiti", + "simsun", + "simhei", + "nsimsun", + "fangsong", + "dengxian", + "microsoft yahei", + ] + .iter() + .any(|name| normalized.contains(name)) + || ["宋体", "黑体", "楷体", "仿宋", "等线", "微软雅黑"] + .iter() + .any(|name| font_name.contains(name)) +} + fn wrap_paragraph_runs(runs: &[DocxRun], max_width: f32) -> Vec> { let mut lines = vec![Vec::new()]; let mut line_width = 0.0; @@ -1141,7 +1676,13 @@ fn push_run_segment(line: &mut Vec, run: &DocxRun, segment: &mut String } fn run_width(run: &DocxRun, text: &str) -> f32 { - styled_text_width_with_font(text, run.font_size, run.bold, run.italic, None) + styled_text_width_with_font( + text, + run.font_size, + run.bold, + run.italic, + run.font_name.as_deref(), + ) } fn render_image( @@ -1201,7 +1742,7 @@ fn render_table( let max_columns = table .rows .iter() - .map(|row| row.cells.len()) + .map(|row| row.cells.iter().map(|cell| cell.grid_span).sum()) .max() .unwrap_or(0); if max_columns == 0 { @@ -1232,54 +1773,64 @@ fn render_table( } } + let table_width: f32 = widths.iter().sum(); + let table_left = match table.alignment { + TextAlignment::Left => margins.left, + TextAlignment::Center => margins.left + (max_width - table_width) / 2.0, + TextAlignment::Right => page_size.width - margins.right - table_width, + }; + + let mut row_heights: Vec = table + .rows + .iter() + .map(|row| { + let mut grid_index = 0; + let content_height = row + .cells + .iter() + .map(|cell| { + let width = spanned_width(&widths, grid_index, cell.grid_span); + grid_index += cell.grid_span; + if cell.vertical_merge != VerticalMerge::None { + return 0.0; + } + table_cell_content_height(table, cell, width) + }) + .fold(0.0_f32, f32::max) + .max(BODY_FONT_SIZE); + row.height + .map_or(content_height, |height| height.max(content_height)) + }) + .collect(); + + for (row_index, row) in table.rows.iter().enumerate() { + let mut grid_index = 0; + for cell in &row.cells { + if cell.vertical_merge == VerticalMerge::Restart { + let mut end_row = row_index; + for next_row in (row_index + 1)..table.rows.len() { + let Some(next_cell) = cell_at_grid(&table.rows[next_row], grid_index) else { + break; + }; + if next_cell.vertical_merge != VerticalMerge::Continue { + break; + } + end_row = next_row; + } + let width = spanned_width(&widths, grid_index, cell.grid_span); + let required_height = table_cell_content_height(table, cell, width); + let current_height: f32 = row_heights[row_index..=end_row].iter().sum(); + if required_height > current_height { + row_heights[end_row] += required_height - current_height; + } + } + grid_index += cell.grid_span; + } + } + let mut row_top = *y + BODY_FONT_SIZE; - for row in &table.rows { - let wrapped_cells: Vec<_> = row - .cells - .iter() - .enumerate() - .map(|(index, cell)| { - let width = widths.get(index).copied().unwrap_or(0.0); - wrap_styled_text( - &cell.text, - (width - TABLE_CELL_PADDING_HORIZONTAL * 2.0).max(1.0), - cell.font_size, - cell.bold, - cell.italic, - ) - }) - .collect(); - let row_height = row - .cells - .iter() - .zip(&wrapped_cells) - .map(|(cell, lines)| { - let text_height = lines.len() as f32 * cell.font_size * 1.18; - let image_height: f32 = cell - .images - .iter() - .map(|image| { - image_render_size( - image, - (widths - .get( - row.cells - .iter() - .position(|candidate| std::ptr::eq(candidate, cell)) - .unwrap_or(0), - ) - .copied() - .unwrap_or(0.0) - - TABLE_CELL_PADDING_HORIZONTAL * 2.0) - .max(1.0), - ) - .1 - }) - .sum(); - text_height + image_height + TABLE_CELL_PADDING_VERTICAL * 2.0 - }) - .fold(0.0_f32, f32::max) - .max(BODY_FONT_SIZE); + for (row_index, row) in table.rows.iter().enumerate() { + let row_height = row_heights[row_index]; if row_top - row_height < margins.bottom { *page_index = doc.pages().len(); @@ -1287,50 +1838,93 @@ fn render_table( row_top = page_size.height - margins.top; } - let mut x = margins.left; - for (index, cell) in row.cells.iter().enumerate() { - let width = widths.get(index).copied().unwrap_or(0.0); - let bottom = row_top - row_height; + let mut grid_index = 0; + for cell in &row.cells { + let x = table_left + widths.iter().take(grid_index).sum::(); + let width = spanned_width(&widths, grid_index, cell.grid_span); + if cell.vertical_merge == VerticalMerge::Continue { + grid_index += cell.grid_span; + continue; + } + let cell_height = if cell.vertical_merge == VerticalMerge::Restart { + merged_cell_height(table, &row_heights, row_index, grid_index) + } else { + row_height + }; + let bottom = row_top - cell_height; let page = doc.page_mut(*page_index).expect("page index is valid"); if let Some(fill) = cell.fill { - page.add_rect(x, bottom, width, row_height, fill); + page.add_rect(x, bottom, width, cell_height, fill); } - let line_height = cell.font_size * 1.18; - let mut text_y = row_top - TABLE_CELL_PADDING_VERTICAL - cell.font_size; - for line in &wrapped_cells[index] { - let text_width = - styled_text_width_with_font(line, cell.font_size, cell.bold, cell.italic, None); + let wrapped_lines = wrap_table_cell_runs( + &cell.runs, + (width - table.cell_margin_left - table.cell_margin_right).max(1.0), + ); + let line_height = table_cell_line_height(table, cell); + let text_height = wrapped_lines.len() as f32 * line_height; + let font_size = table_cell_font_size(cell); + let image_height: f32 = cell + .images + .iter() + .map(|image| { + image_render_size( + image, + (width - table.cell_margin_left - table.cell_margin_right).max(1.0), + ) + .1 + }) + .sum(); + let content_height = + cell.spacing_before + text_height + image_height + cell.spacing_after; + let content_top = match cell.vertical_alignment { + VerticalAlignment::Top => row_top - table.cell_margin_top, + VerticalAlignment::Center => bottom + (cell_height + content_height) / 2.0, + VerticalAlignment::Bottom => bottom + table.cell_margin_bottom + content_height, + }; + let mut text_y = content_top - cell.spacing_before - font_size; + for line in &wrapped_lines { + let text_width: f32 = line.iter().map(|run| run_width(run, &run.text)).sum(); let text_x = match cell.alignment { - TextAlignment::Left => x + TABLE_CELL_PADDING_HORIZONTAL, + TextAlignment::Left => x + table.cell_margin_left, TextAlignment::Center => x + (width - text_width) / 2.0, - TextAlignment::Right => x + width - TABLE_CELL_PADDING_HORIZONTAL - text_width, + TextAlignment::Right => x + width - table.cell_margin_right - text_width, }; - page.add_styled_text( - line, - text_x.max(x + TABLE_BORDER_WIDTH), - text_y, - cell.font_size, - PdfTextStyle { - color: cell.color, - bold: cell.bold, - italic: cell.italic, - preferred_font: None, - }, - ); + let mut run_x = text_x.max(x + TABLE_BORDER_WIDTH); + for run in line { + let width = run_width(run, &run.text); + if let Some(highlight) = run.highlight { + page.add_rect( + run_x - 0.7, + text_y - run.font_size * 0.24, + width + 1.4, + run.font_size * 1.18, + highlight, + ); + } + page.add_styled_text_with_font( + &run.text, + run_x, + text_y, + run.font_size, + run.color, + run.bold, + run.italic, + run.font_name.as_deref(), + ); + run_x += width; + } text_y -= line_height; } - let mut image_top = row_top - - TABLE_CELL_PADDING_VERTICAL - - wrapped_cells[index].len() as f32 * line_height; + let mut image_top = content_top - cell.spacing_before - text_height; for image in &cell.images { let (image_width, image_height) = image_render_size( image, - (width - TABLE_CELL_PADDING_HORIZONTAL * 2.0).max(1.0), + (width - table.cell_margin_left - table.cell_margin_right).max(1.0), ); let image_x = match image.alignment { - TextAlignment::Left => x + TABLE_CELL_PADDING_HORIZONTAL, + TextAlignment::Left => x + table.cell_margin_left, TextAlignment::Center => x + (width - image_width) / 2.0, - TextAlignment::Right => x + width - TABLE_CELL_PADDING_HORIZONTAL - image_width, + TextAlignment::Right => x + width - table.cell_margin_right - image_width, }; let image_id = register_image(doc, image); doc.page_mut(*page_index) @@ -1345,38 +1939,111 @@ fn render_table( image_top -= image_height; } let page = doc.page_mut(*page_index).expect("page index is valid"); - page.add_line( - x, - row_top, - x + width, - row_top, - PdfColor::BLACK, - TABLE_BORDER_WIDTH, - ); - page.add_line( - x, - bottom, - x + width, - bottom, - PdfColor::BLACK, - TABLE_BORDER_WIDTH, - ); - page.add_line(x, bottom, x, row_top, PdfColor::BLACK, TABLE_BORDER_WIDTH); - page.add_line( - x + width, - bottom, - x + width, - row_top, - PdfColor::BLACK, - TABLE_BORDER_WIDTH, - ); - x += width; + if let Some(border) = cell.borders.top { + draw_border(page, (x, row_top), (x + width, row_top), border); + } + if let Some(border) = cell.borders.bottom { + draw_border(page, (x, bottom), (x + width, bottom), border); + } + if let Some(border) = cell.borders.left { + draw_border(page, (x, bottom), (x, row_top), border); + } + if let Some(border) = cell.borders.right { + draw_border(page, (x + width, bottom), (x + width, row_top), border); + } + grid_index += cell.grid_span; } row_top -= row_height; } *y = row_top - BODY_FONT_SIZE; } +fn draw_border( + page: &mut crate::pdf::PdfPage, + start: (f32, f32), + end: (f32, f32), + border: DocxBorder, +) { + let pattern: &[f32] = match border.pattern { + BorderPattern::Solid => &[], + BorderPattern::DotDash => &[1.0, 1.0, 3.0, 1.0], + BorderPattern::Dotted => &[0.8, 1.2], + BorderPattern::Dashed => &[3.0, 2.0], + }; + page.add_line_with_dash_pattern(start, end, border.color, border.width, pattern); +} + +fn spanned_width(widths: &[f32], grid_index: usize, grid_span: usize) -> f32 { + widths.iter().skip(grid_index).take(grid_span).sum() +} + +fn merged_cell_height( + table: &DocxTable, + row_heights: &[f32], + start_row: usize, + grid_index: usize, +) -> f32 { + let mut height = row_heights[start_row]; + for (row_index, row) in table.rows.iter().enumerate().skip(start_row + 1) { + let Some(cell) = cell_at_grid(row, grid_index) else { + break; + }; + if cell.vertical_merge != VerticalMerge::Continue { + break; + } + height += row_heights[row_index]; + } + height +} + +fn cell_at_grid(row: &DocxTableRow, target_grid_index: usize) -> Option<&DocxTableCell> { + let mut grid_index = 0; + for cell in &row.cells { + if grid_index == target_grid_index { + return Some(cell); + } + grid_index += cell.grid_span; + } + None +} + +fn table_cell_content_height(table: &DocxTable, cell: &DocxTableCell, width: f32) -> f32 { + let content_width = (width - table.cell_margin_left - table.cell_margin_right).max(1.0); + let lines = wrap_table_cell_runs(&cell.runs, content_width); + let text_height = cell.spacing_before + + lines.len() as f32 * table_cell_line_height(table, cell) + + cell.spacing_after; + let image_height: f32 = cell + .images + .iter() + .map(|image| image_render_size(image, content_width).1) + .sum(); + text_height + image_height + table.cell_margin_top + table.cell_margin_bottom +} + +fn table_cell_line_height(table: &DocxTable, cell: &DocxTableCell) -> f32 { + let metrics_factor = if table.legacy_cjk_metrics + && cell + .runs + .iter() + .filter_map(|run| run.font_name.as_deref()) + .any(is_tall_cjk_font_name) + { + 1.35 + } else { + 1.18 + }; + table_cell_font_size(cell) * metrics_factor * cell.line_spacing +} + +fn table_cell_font_size(cell: &DocxTableCell) -> f32 { + cell.runs + .iter() + .map(|run| run.font_size) + .reduce(f32::max) + .unwrap_or(BODY_FONT_SIZE) +} + fn image_render_size(image: &DocxImage, max_width: f32) -> (f32, f32) { if image.width <= max_width { return (image.width, image.height); @@ -1400,12 +2067,95 @@ fn register_image(doc: &mut PdfDocument, image: &DocxImage) -> usize { } } -fn wrap_styled_text( +fn wrap_table_cell_runs(runs: &[DocxRun], max_width: f32) -> Vec> { + let Some(first_run) = runs.first() else { + return Vec::new(); + }; + let mut measurement_run = first_run.clone(); + measurement_run.font_name = runs.iter().find_map(|run| run.font_name.clone()); + let (text, styled_characters) = normalize_table_cell_runs(runs); + let wrapped_text = wrap_styled_text_with_font( + &text, + max_width, + measurement_run.font_size, + measurement_run.bold, + measurement_run.italic, + measurement_run.font_name.as_deref(), + ); + let mut characters = styled_characters.into_iter(); + wrapped_text + .into_iter() + .map(|line| { + let mut output = Vec::new(); + for _ in line.chars() { + if let Some(character) = characters.next() { + push_styled_character(&mut output, character); + } + } + output + }) + .collect() +} + +fn normalize_table_cell_runs(runs: &[DocxRun]) -> (String, Vec) { + let mut text = String::new(); + let mut characters = Vec::new(); + let mut pending_space = false; + let mut line_has_text = false; + for run in runs { + for character in run.text.replace('\t', " ").chars() { + if character == '\n' { + text.push('\n'); + pending_space = false; + line_has_text = false; + } else if character.is_whitespace() { + pending_space = line_has_text; + } else { + if pending_space { + let mut space = run.clone(); + space.text = " ".to_owned(); + text.push(' '); + characters.push(space); + } + let mut styled_character = run.clone(); + styled_character.text = character.to_string(); + text.push(character); + characters.push(styled_character); + pending_space = false; + line_has_text = true; + } + } + } + (text, characters) +} + +fn push_styled_character(line: &mut Vec, character: DocxRun) { + if let Some(last) = line.last_mut() { + if same_run_style(last, &character) { + last.text.push_str(&character.text); + return; + } + } + line.push(character); +} + +fn same_run_style(left: &DocxRun, right: &DocxRun) -> bool { + left.font_size == right.font_size + && left.font_name == right.font_name + && left.bold == right.bold + && left.italic == right.italic + && left.underline == right.underline + && left.color == right.color + && left.highlight == right.highlight +} + +fn wrap_styled_text_with_font( text: &str, max_width: f32, font_size: f32, bold: bool, italic: bool, + font_name: Option<&str>, ) -> Vec { let text = text.replace('\t', " "); let mut lines = Vec::new(); @@ -1419,21 +2169,25 @@ fn wrap_styled_text( format!("{current} {word}") }; - if styled_text_width_with_font(&candidate, font_size, bold, italic, None) <= max_width { + if styled_text_width_with_font(&candidate, font_size, bold, italic, font_name) + <= max_width + { current = candidate; + } else if word.chars().next().is_some_and(is_forbidden_line_start) + && !current.is_empty() + { + let trailing = current.pop().expect("preceding character exists"); + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + current.push(trailing); + current.push_str(word); } else { if !current.is_empty() { lines.push(current); } - current = String::new(); - append_wrapped_word( - &mut lines, - &mut current, - word, - max_width, - font_size, - bold, - italic, + current = append_wrapped_word( + &mut lines, word, max_width, font_size, bold, italic, font_name, ); } } @@ -1450,24 +2204,23 @@ fn wrap_styled_text( fn append_wrapped_word( lines: &mut Vec, - current: &mut String, word: &str, max_width: f32, font_size: f32, bold: bool, italic: bool, -) { - if styled_text_width_with_font(word, font_size, bold, italic, None) <= max_width { - current.push_str(word); - return; + font_name: Option<&str>, +) -> String { + if styled_text_width_with_font(word, font_size, bold, italic, font_name) <= max_width { + return word.to_owned(); } - for chunk in split_word_to_width(word, max_width, font_size, bold, italic) { - if !current.is_empty() { - lines.push(std::mem::take(current)); - } - current.push_str(&chunk); + let mut chunks = split_word_to_width(word, max_width, font_size, bold, italic, font_name); + let current = chunks.pop().unwrap_or_default(); + for chunk in chunks { + lines.push(chunk); } + current } fn split_word_to_width( @@ -1476,6 +2229,7 @@ fn split_word_to_width( font_size: f32, bold: bool, italic: bool, + font_name: Option<&str>, ) -> Vec { let mut chunks = Vec::new(); let mut current = String::new(); @@ -1484,9 +2238,24 @@ fn split_word_to_width( let mut candidate = current.clone(); candidate.push(ch); if !current.is_empty() - && styled_text_width_with_font(&candidate, font_size, bold, italic, None) > max_width + && styled_text_width_with_font(&candidate, font_size, bold, italic, font_name) + > max_width { - chunks.push(std::mem::take(&mut current)); + if is_forbidden_line_start(ch) { + let trailing = current.pop().expect("preceding character exists"); + if !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + } + current.push(trailing); + } else if current.chars().last().is_some_and(is_forbidden_line_end) { + let opening = current.pop().expect("line-ending character exists"); + if !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + } + current.push(opening); + } else { + chunks.push(std::mem::take(&mut current)); + } } current.push(ch); } @@ -1498,6 +2267,20 @@ fn split_word_to_width( chunks } +fn is_forbidden_line_end(ch: char) -> bool { + matches!( + ch, + '(' | '[' | '{' | '(' | '[' | '{' | '《' | '〈' | '「' | '『' + ) +} + +fn is_forbidden_line_start(ch: char) -> bool { + matches!( + ch, + ')' | ']' | '}' | ')' | ']' | '}' | '》' | '〉' | '」' | '』' + ) +} + #[cfg(test)] mod tests { use std::io::{Cursor, Write}; @@ -1505,8 +2288,8 @@ mod tests { use zip::write::SimpleFileOptions; use super::{ - convert_docx_bytes, plain_paragraph, read_docx_document, wrap_styled_text, DocxBlock, - DocxImageData, + convert_docx_bytes, plain_paragraph, read_docx_document, wrap_styled_text_with_font, + DocxBlock, DocxImageData, }; use crate::ConversionOptions; @@ -1523,6 +2306,24 @@ mod tests { output.into_inner() } + fn create_docx_with_settings(document_xml: &str, settings_xml: &str) -> Vec { + let mut output = Cursor::new(Vec::new()); + { + let mut archive = zip::ZipWriter::new(&mut output); + for (path, data) in [ + ("word/document.xml", document_xml.as_bytes()), + ("word/settings.xml", settings_xml.as_bytes()), + ] { + archive + .start_file(path, SimpleFileOptions::default()) + .unwrap(); + archive.write_all(data).unwrap(); + } + archive.finish().unwrap(); + } + output.into_inner() + } + fn create_docx_with_image(document_xml: &str, relationships_xml: &str) -> Vec { let image = image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255])); let mut png = Cursor::new(Vec::new()); @@ -1551,7 +2352,7 @@ mod tests { #[test] fn wraps_unspaced_text_without_truncating() { let text = "abcdefghijklmnopqrstuvwxyz"; - let lines = wrap_styled_text(text, 40.0, 12.0, false, false); + let lines = wrap_styled_text_with_font(text, 40.0, 12.0, false, false, None); assert!(lines.len() > 1); assert_eq!(lines.concat(), text); @@ -1560,6 +2361,18 @@ mod tests { })); } + #[test] + fn keeps_cjk_opening_punctuation_off_line_end() { + let text = "机房(扶梯上机舱 )"; + let max_width = + crate::pdf::styled_text_width_with_font("机房(", 8.0, true, false, Some("宋体")); + + let lines = wrap_styled_text_with_font(text, max_width, 8.0, true, false, Some("宋体")); + + assert_eq!(lines, vec!["机房", "(扶梯", "上机", "舱)"]); + assert!(lines.iter().all(|line| !line.ends_with('('))); + } + #[test] fn preserves_explicit_page_breaks() { let input = create_docx( @@ -1601,10 +2414,11 @@ mod tests { panic!("expected table block"); }; assert_eq!(table.column_widths, vec![100.0, 150.0]); + assert_eq!(table.alignment, super::TextAlignment::Left); assert_eq!(table.rows[0].cells.len(), 2); - assert_eq!(table.rows[0].cells[0].font_size, 8.0); - assert!(table.rows[0].cells[0].bold); - assert_eq!(table.rows[0].cells[0].text, "Header"); + assert_eq!(table.rows[0].cells[0].runs[0].font_size, 8.0); + assert!(table.rows[0].cells[0].runs[0].bold); + assert_eq!(table.rows[0].cells[0].runs[0].text, "Header"); assert!(table.rows[0].cells[0].fill.is_some()); let pdf = convert_docx_bytes(&input, &ConversionOptions::default()).unwrap(); @@ -1616,6 +2430,78 @@ mod tests { ); } + #[test] + fn reads_table_spans_merges_and_declared_height() { + let input = create_docx( + r#"ABC"#, + ); + + let document = read_docx_document(&input).unwrap(); + let DocxBlock::Table(table) = &document.blocks[0] else { + panic!("expected table block"); + }; + + assert_eq!(table.alignment, super::TextAlignment::Center); + assert_eq!(table.rows[0].height, Some(12.0)); + assert_eq!(table.rows[0].cells[1].grid_span, 2); + assert_eq!( + table.rows[0].cells[0].vertical_merge, + super::VerticalMerge::Restart + ); + assert_eq!( + table.rows[1].cells[0].vertical_merge, + super::VerticalMerge::Continue + ); + assert_eq!( + table.rows[0].cells[0].vertical_alignment, + super::VerticalAlignment::Center + ); + } + + #[test] + fn reads_table_font_highlight_margins_and_borders() { + let input = create_docx( + r#"高亮"#, + ); + + let document = read_docx_document(&input).unwrap(); + let DocxBlock::Table(table) = &document.blocks[0] else { + panic!("expected table block"); + }; + let cell = &table.rows[0].cells[0]; + + assert_eq!(table.cell_margin_left, 0.0); + assert_eq!(table.cell_margin_right, 0.0); + assert_eq!(cell.spacing_before, 9.0); + assert_eq!(cell.spacing_after, 6.0); + assert_eq!(cell.line_spacing, 1.15); + assert_eq!(cell.runs.len(), 2); + assert_eq!(cell.runs[0].font_name, None); + assert_eq!(cell.runs[1].font_name.as_deref(), Some("宋体")); + assert_eq!(cell.runs[1].font_size, 9.0); + assert_eq!( + cell.runs[1].highlight, + Some(crate::PdfColor::new(1.0, 1.0, 0.0)) + ); + assert_eq!( + cell.borders.top.map(|border| border.pattern), + Some(super::BorderPattern::DotDash) + ); + assert_eq!(cell.borders.right, None); + } + + #[test] + fn reads_word_2007_compatibility_mode() { + let input = create_docx_with_settings( + r#"Legacy"#, + r#""#, + ); + + let document = read_docx_document(&input).unwrap(); + + assert_eq!(document.compatibility_mode, 12); + } + #[test] fn reads_and_renders_inline_png() { let input = create_docx_with_image( @@ -1644,4 +2530,25 @@ mod tests { .windows(b"/Subtype /Image".len()) .any(|chunk| chunk == b"/Subtype /Image")); } + + #[test] + fn anchors_vml_textbox_image_once() { + let input = create_docx_with_image( + r#"Title"#, + r#""#, + ); + + let document = read_docx_document(&input).unwrap(); + + assert_eq!(document.blocks.len(), 1); + let DocxBlock::Paragraph(paragraph) = &document.blocks[0] else { + panic!("expected paragraph block"); + }; + assert_eq!(paragraph.runs.len(), 1); + assert_eq!(paragraph.runs[0].text, "Title"); + assert_eq!(paragraph.floating_images.len(), 1); + assert_eq!(paragraph.floating_images[0].offset_x, 393.95); + assert_eq!(paragraph.floating_images[0].offset_y, -1.9); + assert_eq!(paragraph.floating_images[0].line_top_leading, 2.0); + } } diff --git a/minipdf-rs/crates/minipdf/src/pdf.rs b/minipdf-rs/crates/minipdf/src/pdf.rs index 98303ea9..ef53ae4d 100644 --- a/minipdf-rs/crates/minipdf/src/pdf.rs +++ b/minipdf-rs/crates/minipdf/src/pdf.rs @@ -697,7 +697,11 @@ fn font_preference( ) -> u8 { let name = name.to_ascii_lowercase(); if let Some(preferred) = preferred_font { - let preferred = preferred.to_ascii_lowercase(); + let preferred = match preferred.trim() { + "宋体" | "新宋体" => "simsun".to_owned(), + "黑体" => "simhei".to_owned(), + preferred => preferred.to_ascii_lowercase(), + }; let preferred_variant = match (preferred.as_str(), bold, italic) { ("arial", true, true) => "arialbi", ("arial", true, false) => "arialbd", @@ -1191,6 +1195,18 @@ mod tests { ); } + #[test] + fn prefers_simsun_for_localized_docx_font_name() { + assert_eq!( + font_preference("simsun", '学', false, false, Some("宋体")), + 0 + ); + assert_eq!( + font_preference("simhei", '学', true, false, Some("黑体")), + 0 + ); + } + #[test] fn prefers_matching_verdana_style_variant() { assert_eq!(