diff --git a/Cargo.lock b/Cargo.lock index 5804a6984..da44d4958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,6 +247,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "data-url" version = "0.3.2" @@ -299,6 +305,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -430,6 +442,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -455,6 +478,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "tiff", "zune-core 0.5.1", "zune-jpeg 0.5.15", ] @@ -1266,6 +1290,20 @@ dependencies = [ "xattr", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.5.15", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -1701,6 +1739,26 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zip" version = "8.6.0" diff --git a/Cargo.toml b/Cargo.toml index 4c8a5f9d8..eef996dc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ snafu = "0.9.0" strum = { version = "0.28.0", default-features = false, features = ["derive"] } unicode-segmentation = "1.12.0" unicode-width = "0.2.2" -image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "png"] } +image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "png", "tiff"] } pcx = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -62,6 +62,7 @@ web-sys = { version = "0.3", features = [ "CanvasPattern", "HtmlCanvasElement", "HtmlImageElement", + "ImageData", "TextMetrics", "Document", "Window", diff --git a/examples/diag_blank_pages.rs b/examples/diag_blank_pages.rs new file mode 100644 index 000000000..fcdbc2b10 --- /dev/null +++ b/examples/diag_blank_pages.rs @@ -0,0 +1,138 @@ +//! 진단: HWPX/HWP 본문 페이지에 본문 1줄만 배치되고 거대한 빈 공간이 생기는 +//! "near-blank page" 결함을 네이티브로 재현한다. +//! +//! WASM 바인딩 `pageCount`/`getPageTextLayout`/`getPageFootnoteInfo` 가 호출하는 +//! 동일 내부 경로(`DocumentCore`)를 직접 호출한다. +//! +//! 사용: `cargo run --release --example diag_blank_pages -- [from] [to]` +//! 기본 출력 범위: 0-indexed page 8..=20. + +use rhwp::wasm_api::HwpDocument; +use std::env; +use std::fs; + +fn main() { + let args: Vec = env::args().skip(1).collect(); + if args.is_empty() { + eprintln!( + "사용: cargo run --release --example diag_blank_pages -- [from] [to]" + ); + std::process::exit(1); + } + let file = &args[0]; + + // --coltype : 문단별 column_type/page_break_before/텍스트 덤프 + // (강제 쪽나누기 속성 검증용 — orphan break 의 원인 분류). + if args.get(1).map(|s| s == "--coltype").unwrap_or(false) { + let sec: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(1); + let from_p: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0); + let to_p: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(60); + let data = fs::read(file).expect("read file"); + let doc = HwpDocument::from_bytes(&data).expect("parse document"); + let dpi = 96.0_f64; + let styles = rhwp::renderer::style_resolver::resolve_styles(&doc.document().doc_info, dpi); + let section = &doc.document().sections[sec]; + println!( + "sec {} paragraphs (col_type / page_break_before / text)", + sec + ); + for (pi, p) in section.paragraphs.iter().enumerate() { + if pi < from_p || pi > to_p { + continue; + } + let pbb = styles + .para_styles + .get(p.para_shape_id as usize) + .map(|s| s.page_break_before) + .unwrap_or(false); + let text: String = p.text.chars().take(28).collect(); + println!( + "pi={:>4} col_type={:?} pbb={} lines={} ctrls={} text={:?}", + pi, + p.column_type, + pbb, + p.line_segs.len(), + p.controls.len(), + text.trim() + ); + } + return; + } + + let from: u32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(8); + let to: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(20); + + let data = fs::read(file).expect("read file"); + let doc = HwpDocument::from_bytes(&data).expect("parse document"); + + let total = doc.page_count(); + println!("file: {}", file); + println!("TOTAL PAGES: {}", total); + println!( + "{:>4} | {:>3} | {:>9} | {:>9} | {:>9} | {:>7} | {:>7} | {:>7} | {:>7}", + "page", + "sec", + "bodyLines", + "bodyMinY", + "bodyMaxY", + "fnLines", + "fnCount", + "paraMin", + "paraMax" + ); + + let end = to.min(total.saturating_sub(1)); + for page in from..=end { + match doc.diag_page_layout_native(page) { + Ok(json) => { + let sec = extract_int(&json, "sectionIdx"); + let body_lines = extract_int(&json, "bodyLines"); + let body_min = extract_f64(&json, "bodyMinY"); + let body_max = extract_f64(&json, "bodyMaxY"); + let fn_lines = extract_int(&json, "footnoteLines"); + let fn_count = extract_int(&json, "footnoteCount"); + let para_min = extract_int(&json, "paraMin"); + let para_max = extract_int(&json, "paraMax"); + println!( + "{:>4} | {:>3} | {:>9} | {:>9.1} | {:>9.1} | {:>7} | {:>7} | {:>7} | {:>7}", + page, + sec, + body_lines, + body_min, + body_max, + fn_lines, + fn_count, + para_min, + para_max + ); + } + Err(e) => { + println!("{:>4} | ERROR: {:?}", page, e); + } + } + } +} + +/// 미니 JSON 정수 추출: `"key":` 패턴. +fn extract_int(json: &str, key: &str) -> i64 { + extract_raw(json, key) + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(-1) +} + +/// 미니 JSON 실수 추출. +fn extract_f64(json: &str, key: &str) -> f64 { + extract_raw(json, key) + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(f64::NAN) +} + +fn extract_raw(json: &str, key: &str) -> Option { + let needle = format!("\"{}\":", key); + let start = json.find(&needle)? + needle.len(); + let rest = &json[start..]; + let end = rest + .find(|c: char| c == ',' || c == '}') + .unwrap_or(rest.len()); + Some(rest[..end].to_string()) +} diff --git a/rhwp-studio/src/core/font-loader.ts b/rhwp-studio/src/core/font-loader.ts index 5c4ff831d..9f51a1012 100644 --- a/rhwp-studio/src/core/font-loader.ts +++ b/rhwp-studio/src/core/font-loader.ts @@ -64,6 +64,15 @@ const FONT_LIST: FontEntry[] = [ // Haansoft Dotum: HWP 문서가 직접 지정하는 한컴 돋움 영문명(예: 수능 모의고사 본문). // 기존 미등록 → 체인의 'Malgun Gothic'(Pretendard) 가 먼저 매칭되어 굵게 렌더됐다. { name: 'Haansoft Dotum', file: 'fonts/NotoSansKR-ExtraLight.woff2' }, + { name: 'KoPub돋움체 Light', file: 'fonts/NotoSansKR-ExtraLight.woff2' }, + { name: 'KoPub Dotum Light', file: 'fonts/NotoSansKR-ExtraLight.woff2' }, + { name: 'KoPubWorld돋움체 Light', file: 'fonts/NotoSansKR-ExtraLight.woff2' }, + { name: 'KoPub바탕체 Light', file: 'fonts/NotoSerifKR-Regular.woff2' }, + { name: 'KoPub바탕체 Medium', file: 'fonts/NotoSerifKR-Regular.woff2' }, + { name: 'KoPub바탕체 Bold', file: 'fonts/NotoSerifKR-Bold.woff2' }, + { name: 'KoPub Batang Light', file: 'fonts/NotoSerifKR-Regular.woff2' }, + { name: 'KoPub Batang Medium', file: 'fonts/NotoSerifKR-Regular.woff2' }, + { name: 'KoPub Batang Bold', file: 'fonts/NotoSerifKR-Bold.woff2' }, { name: '바탕', file: 'fonts/NotoSerifKR-Regular.woff2' }, { name: '바탕체', file: 'fonts/D2Coding-Regular.woff2' }, { name: '궁서', file: 'fonts/GowunBatang-Regular.woff2' }, diff --git a/src/document_core/queries/rendering.rs b/src/document_core/queries/rendering.rs index ab9321e29..922d28812 100644 --- a/src/document_core/queries/rendering.rs +++ b/src/document_core/queries/rendering.rs @@ -123,6 +123,8 @@ fn assign_master_pages_for_section( result: &mut PaginationResult, section_index: usize, section: &Section, + carry_master_odd: &Option, + carry_master_even: &Option, ) { use crate::model::header_footer::HeaderFooterApply; @@ -136,15 +138,29 @@ fn assign_master_pages_for_section( return; } - let mp_both = mps + let base_mp_indices: Vec = mps .iter() - .position(|m| m.apply_to == HeaderFooterApply::Both && !m.is_extension); - let mp_odd = mps + .enumerate() + .filter(|(_, m)| !m.is_extension) + .map(|(i, _)| i) + .collect(); + let single_base_mp = if base_mp_indices.len() == 1 { + base_mp_indices.first().copied() + } else { + None + }; + let mp_both = base_mp_indices + .iter() + .copied() + .find(|&i| mps[i].apply_to == HeaderFooterApply::Both); + let mp_odd = base_mp_indices .iter() - .position(|m| m.apply_to == HeaderFooterApply::Odd && !m.is_extension); - let mp_even = mps + .copied() + .find(|&i| mps[i].apply_to == HeaderFooterApply::Odd); + let mp_even = base_mp_indices .iter() - .position(|m| m.apply_to == HeaderFooterApply::Even && !m.is_extension); + .copied() + .find(|&i| mps[i].apply_to == HeaderFooterApply::Even); let ext_mp_indices: Vec = mps .iter() .enumerate() @@ -162,14 +178,35 @@ fn assign_master_pages_for_section( } let selected = if page.page_number % 2 == 1 { - mp_odd.or(mp_both) + mp_odd + .or(mp_both) + .map(|mi| MasterPageRef { + section_index, + master_page_index: mi, + }) + .or_else(|| carry_master_odd.clone()) + .or_else(|| { + single_base_mp.map(|mi| MasterPageRef { + section_index, + master_page_index: mi, + }) + }) } else { - mp_even.or(mp_both) + mp_even + .or(mp_both) + .map(|mi| MasterPageRef { + section_index, + master_page_index: mi, + }) + .or_else(|| carry_master_even.clone()) + .or_else(|| { + single_base_mp.map(|mi| MasterPageRef { + section_index, + master_page_index: mi, + }) + }) }; - page.active_master_page = selected.map(|mi| MasterPageRef { - section_index, - master_page_index: mi, - }); + page.active_master_page = selected; if is_last && !ext_mp_indices.is_empty() { let replace_exts: Vec = ext_mp_indices @@ -193,7 +230,13 @@ fn assign_master_pages_for_section( let active_apply = page .active_master_page .as_ref() - .and_then(|mp_ref| mps.get(mp_ref.master_page_index)) + .and_then(|mp_ref| { + if mp_ref.section_index == section_index { + mps.get(mp_ref.master_page_index) + } else { + None + } + }) .map(|m| m.apply_to); let mut remaining_overlap_exts: Vec = Vec::new(); for &i in &overlap_exts { @@ -219,6 +262,33 @@ fn assign_master_pages_for_section( } } +fn update_master_page_carry_from_section( + section_index: usize, + section: &Section, + carry_master_odd: &mut Option, + carry_master_even: &mut Option, +) { + use crate::model::header_footer::HeaderFooterApply; + + for (master_page_index, master_page) in section.section_def.master_pages.iter().enumerate() { + if master_page.is_extension { + continue; + } + let master_ref = MasterPageRef { + section_index, + master_page_index, + }; + match master_page.apply_to { + HeaderFooterApply::Both => { + *carry_master_odd = Some(master_ref.clone()); + *carry_master_even = Some(master_ref); + } + HeaderFooterApply::Odd => *carry_master_odd = Some(master_ref), + HeaderFooterApply::Even => *carry_master_even = Some(master_ref), + } + } +} + fn apply_page_number_layouts_for_section(result: &mut PaginationResult, section: &Section) { let page_def = §ion.section_def.page_def; for page in &mut result.pages { @@ -1539,6 +1609,98 @@ impl DocumentCore { Ok(format!("{{\"runs\":[{}]}}", runs.join(","))) } + /// 진단용: 페이지의 본문/각주 줄 분포 요약 (examples/diag_blank_pages.rs 전용). + /// + /// FootnoteArea 아래의 TextRun 은 각주, 그 밖의 TextRun 은 본문으로 분류한다. + /// 반환 JSON: sectionIdx, bodyLines, bodyMinY, bodyMaxY, footnoteLines, footnoteCount. + pub fn diag_page_layout_native(&self, page_num: u32) -> Result { + use crate::renderer::render_tree::{RenderNode, RenderNodeType}; + + let tree = self.build_page_tree(page_num)?; + + // 본문/각주 TextRun bbox.y 수집. in_footnote 플래그로 영역 구분. + fn collect( + node: &RenderNode, + in_footnote: bool, + body: &mut Vec, + footnote: &mut Vec, + body_paras: &mut Vec, + ) { + let now_in_footnote = + in_footnote || matches!(node.node_type, RenderNodeType::FootnoteArea); + if let RenderNodeType::TextRun(ref tr) = node.node_type { + if now_in_footnote { + footnote.push(node.bbox.y); + } else { + body.push(node.bbox.y); + if let Some(pi) = tr.para_index { + body_paras.push(pi); + } + } + } + for child in &node.children { + collect(child, now_in_footnote, body, footnote, body_paras); + } + } + + let mut body: Vec = Vec::new(); + let mut footnote: Vec = Vec::new(); + let mut body_paras: Vec = Vec::new(); + collect(&tree.root, false, &mut body, &mut footnote, &mut body_paras); + + let (section_idx, footnote_count) = self.diag_page_section_and_footnote_count(page_num); + + let body_min = if body.is_empty() { + 0.0 + } else { + body.iter().copied().fold(f64::INFINITY, f64::min) + }; + let body_max = if body.is_empty() { + 0.0 + } else { + body.iter().copied().fold(f64::NEG_INFINITY, f64::max) + }; + let para_min = body_paras + .iter() + .copied() + .min() + .map(|v| v as i64) + .unwrap_or(-1); + let para_max = body_paras + .iter() + .copied() + .max() + .map(|v| v as i64) + .unwrap_or(-1); + + Ok(format!( + "{{\"sectionIdx\":{},\"bodyLines\":{},\"bodyMinY\":{:.1},\"bodyMaxY\":{:.1},\"footnoteLines\":{},\"footnoteCount\":{},\"paraMin\":{},\"paraMax\":{}}}", + section_idx, + body.len(), + body_min, + body_max, + footnote.len(), + footnote_count, + para_min, + para_max, + )) + } + + /// 진단용: 페이지의 (구역 인덱스, 각주 개수) 반환. + pub fn diag_page_section_and_footnote_count(&self, page_num: u32) -> (usize, usize) { + let mut offset = 0u32; + for (si, pr) in self.pagination.iter().enumerate() { + let count = pr.pages.len() as u32; + if page_num < offset + count { + let local = (page_num - offset) as usize; + let fn_count = pr.pages.get(local).map(|p| p.footnotes.len()).unwrap_or(0); + return (si, fn_count); + } + offset += count; + } + (0, 0) + } + /// 컨트롤(표, 이미지 등) 레이아웃 정보 (네이티브 에러 타입) pub fn get_page_control_layout_native(&self, page_num: u32) -> Result { use crate::renderer::render_tree::{RenderNode, RenderNodeType}; @@ -2152,6 +2314,8 @@ impl DocumentCore { let mut carry_header_even: Option = None; let mut carry_footer_odd: Option = None; let mut carry_footer_even: Option = None; + let mut carry_master_odd: Option = None; + let mut carry_master_even: Option = None; // [Task #1046] reflow force-break hint (구역별). reflow 루프(paginate)가 누적해 전달. let empty_breaks: std::collections::HashSet = std::collections::HashSet::new(); @@ -2184,6 +2348,12 @@ impl DocumentCore { } } } + update_master_page_carry_from_section( + idx, + section, + &mut carry_master_odd, + &mut carry_master_even, + ); continue; } @@ -2257,6 +2427,7 @@ impl DocumentCore { hide_empty_line: section.section_def.hide_empty_line, respect_vpos_reset: self.respect_vpos_reset, is_hwp3_variant: self.document.is_hwp3_variant, + footnote_shape: Some(section.section_def.footnote_shape.clone()), }, ) } else { @@ -2274,6 +2445,7 @@ impl DocumentCore { self.document.is_hwp3_variant, hwp3_origin_flow_spacing_before, hwp3_origin_page_tolerance, + Some(§ion.section_def.footnote_shape), Some(§ion.section_def.endnote_shape), force_breaks.get(idx).unwrap_or(&empty_breaks), is_hwpx_source, @@ -2430,7 +2602,19 @@ impl DocumentCore { apply_page_number_layouts_for_section(&mut result, section); // 바탕쪽 선택은 구역 간 쪽번호 carry 보정 이후 최종 page_number 기준으로 수행한다. - assign_master_pages_for_section(&mut result, idx, section); + assign_master_pages_for_section( + &mut result, + idx, + section, + &carry_master_odd, + &carry_master_even, + ); + update_master_page_carry_from_section( + idx, + section, + &mut carry_master_odd, + &mut carry_master_even, + ); // 구역 간 머리말/꼬리말 상속 (쪽번호 보정 이후 실행) if idx > 0 { @@ -4139,6 +4323,127 @@ mod tests { ); } + #[test] + fn missing_even_master_page_inherits_previous_even_master() { + use crate::model::document::{Document, Section, SectionDef}; + use crate::model::header_footer::{HeaderFooterApply, MasterPage}; + use crate::model::page::PageDef; + use crate::model::paragraph::Paragraph; + + fn a4_section(section_def: SectionDef) -> Section { + Section { + section_def, + paragraphs: vec![Paragraph::default()], + raw_stream: None, + } + } + + let page_def = PageDef { + width: 59528, + height: 84188, + margin_left: 8504, + margin_right: 8504, + margin_top: 5668, + margin_bottom: 4252, + margin_header: 4252, + margin_footer: 4252, + ..Default::default() + }; + + let mut document = Document::default(); + document.sections.push(a4_section(SectionDef { + page_def: page_def.clone(), + master_pages: vec![MasterPage { + apply_to: HeaderFooterApply::Even, + ..Default::default() + }], + ..Default::default() + })); + document.sections.push(a4_section(SectionDef { + page_def, + master_pages: vec![MasterPage { + apply_to: HeaderFooterApply::Odd, + ..Default::default() + }], + ..Default::default() + })); + + let mut core = DocumentCore::new_empty(); + core.set_document(document); + core.paginate(); + + let page = core + .pagination + .get(1) + .and_then(|result| result.pages.first()) + .expect("section 1 first page"); + assert_eq!(page.page_number, 2); + let active = page + .active_master_page + .as_ref() + .expect("section 1 even page should inherit previous even master page"); + assert_eq!(active.section_index, 0); + assert_eq!(active.master_page_index, 0); + } + + #[test] + fn single_base_master_page_applies_when_matching_parity_is_absent() { + use crate::model::document::{Document, Section, SectionDef}; + use crate::model::header_footer::{HeaderFooterApply, MasterPage}; + use crate::model::page::PageDef; + use crate::model::paragraph::Paragraph; + + fn a4_section(section_def: SectionDef) -> Section { + Section { + section_def, + paragraphs: vec![Paragraph::default()], + raw_stream: None, + } + } + + let page_def = PageDef { + width: 59528, + height: 84188, + margin_left: 8504, + margin_right: 8504, + margin_top: 5668, + margin_bottom: 4252, + margin_header: 4252, + margin_footer: 4252, + ..Default::default() + }; + + let mut document = Document::default(); + document.sections.push(a4_section(SectionDef { + page_def: page_def.clone(), + ..Default::default() + })); + document.sections.push(a4_section(SectionDef { + page_def, + master_pages: vec![MasterPage { + apply_to: HeaderFooterApply::Odd, + ..Default::default() + }], + ..Default::default() + })); + + let mut core = DocumentCore::new_empty(); + core.set_document(document); + core.paginate(); + + let page = core + .pagination + .get(1) + .and_then(|result| result.pages.first()) + .expect("section 1 first page"); + assert_eq!(page.page_number, 2); + let active = page + .active_master_page + .as_ref() + .expect("single base master should apply to carried even page"); + assert_eq!(active.master_page_index, 0); + } + #[test] fn page_border_fill_api_updates_basis_spacing_and_border() { use crate::model::document::{Document, Section, SectionDef}; diff --git a/src/model/style.rs b/src/model/style.rs index c44162bd8..36e296681 100644 --- a/src/model/style.rs +++ b/src/model/style.rs @@ -656,6 +656,7 @@ pub enum ImageFillMode { TileVertLeft, TileVertRight, FitToSize, + Total, Center, CenterTop, CenterBottom, diff --git a/src/paint/json.rs b/src/paint/json.rs index 10dc2201f..b2a2150e2 100644 --- a/src/paint/json.rs +++ b/src/paint/json.rs @@ -825,6 +825,16 @@ impl PaintOp { Some(png) => ("image/png", std::borrow::Cow::Owned(png)), None => (mime, std::borrow::Cow::Borrowed(data.as_slice())), } + } else if mime == "image/tiff" { + match crate::renderer::svg::tiff_bytes_to_png_bytes(data) { + Some(png) => ("image/png", std::borrow::Cow::Owned(png)), + None => (mime, std::borrow::Cow::Borrowed(data.as_slice())), + } + } else if mime == "image/jpeg" { + match crate::renderer::svg::grayscale_jpeg_bytes_to_png_bytes(data) { + Some(png) => ("image/png", std::borrow::Cow::Owned(png)), + None => (mime, std::borrow::Cow::Borrowed(data.as_slice())), + } } else { (mime, std::borrow::Cow::Borrowed(data.as_slice())) }; @@ -2476,6 +2486,7 @@ fn image_fill_mode_str(value: ImageFillMode) -> &'static str { ImageFillMode::TileVertLeft => "tileVertLeft", ImageFillMode::TileVertRight => "tileVertRight", ImageFillMode::FitToSize => "fitToSize", + ImageFillMode::Total => "total", ImageFillMode::Center => "center", ImageFillMode::CenterTop => "centerTop", ImageFillMode::CenterBottom => "centerBottom", diff --git a/src/parser/hwpx/header.rs b/src/parser/hwpx/header.rs index 99e0d2b8a..72e48eb5b 100644 --- a/src/parser/hwpx/header.rs +++ b/src/parser/hwpx/header.rs @@ -1465,9 +1465,10 @@ fn parse_border_fill( "CENTER" => ImageFillMode::Center, "CENTER_TOP" => ImageFillMode::CenterTop, "CENTER_BOTTOM" => ImageFillMode::CenterBottom, - "FIT" | "FIT_TO_SIZE" | "STRETCH" | "TOTAL" => { + "FIT" | "FIT_TO_SIZE" | "STRETCH" => { ImageFillMode::FitToSize } + "TOTAL" => ImageFillMode::Total, "TOP_LEFT_ALIGN" => ImageFillMode::LeftTop, _ => ImageFillMode::TileAll, }; @@ -2648,6 +2649,23 @@ mod tests { .expect("borderFill 파싱 실패") } + #[test] + fn test_img_brush_total_keeps_total_mode() { + let bf = parse_single_border_fill( + r#" + + + + + + "#, + ); + + let img = bf.fill.image.expect("image brush"); + assert_eq!(img.fill_mode, ImageFillMode::Total); + assert_eq!(img.bin_data_id, 36); + } + #[test] fn test_slash_center_without_diagonal_no_line() { // #1038 회귀 가드: slash type="CENTER" 만 있고 가 없으면 diff --git a/src/parser/hwpx/section.rs b/src/parser/hwpx/section.rs index b63ffcf14..9ca784e5f 100644 --- a/src/parser/hwpx/section.rs +++ b/src/parser/hwpx/section.rs @@ -23,9 +23,10 @@ use crate::model::page::{ }; use crate::model::paragraph::{CharShapeRef, FieldRange, LineSeg, OrphanFieldEnd, Paragraph}; use crate::model::shape::{ - ArcShape, CommonObjAttr, CurveShape, DrawingObjAttr, EllipseShape, GroupShape, HorzAlign, - HorzRelTo, LineShape, PolygonShape, RectangleShape, ShapeComponentAttr, ShapeObject, - SizeCriterion, TextBox, TextWrap, VertAlign, VertRelTo, + ArcShape, CommonObjAttr, ConnectorControlPoint, ConnectorData, CurveShape, DrawingObjAttr, + EllipseShape, GroupShape, HorzAlign, HorzRelTo, LineShape, LinkLineType, PolygonShape, + RectangleShape, ShapeComponentAttr, ShapeObject, SizeCriterion, TextBox, TextWrap, VertAlign, + VertRelTo, }; use crate::model::style::{Fill, ShapeBorderLine}; use crate::model::table::{Cell, Table, TablePageBreak, VerticalAlign}; @@ -485,7 +486,8 @@ fn parse_paragraph( // lineseg 배열 파싱 parse_lineseg_array(reader, &mut para)?; } - b"rect" | b"ellipse" | b"line" | b"arc" | b"polygon" | b"curve" => { + b"rect" | b"ellipse" | b"line" | b"connectLine" | b"arc" | b"polygon" + | b"curve" => { // 그리기 객체 파싱 let shape = parse_shape_object(local, ce, reader)?; text_parts.push("\u{0002}".to_string()); @@ -3276,6 +3278,26 @@ fn parse_line_shape_attr(e: &quick_xml::events::BytesStart) -> ShapeBorderLine { bl } +fn parse_connect_line_type_attr(e: &quick_xml::events::BytesStart) -> LinkLineType { + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"type" { + return match attr_str(&attr).to_ascii_uppercase().as_str() { + "STRAIGHT_ONEWAY" => LinkLineType::StraightOneWay, + "STRAIGHT_BOTH" => LinkLineType::StraightBoth, + "STROKE_NOARROW" => LinkLineType::StrokeNoArrow, + "STROKE_ONEWAY" => LinkLineType::StrokeOneWay, + "STROKE_BOTH" => LinkLineType::StrokeBoth, + "ARC_NOARROW" => LinkLineType::ArcNoArrow, + "ARC_ONEWAY" => LinkLineType::ArcOneWay, + "ARC_BOTH" => LinkLineType::ArcBoth, + _ => LinkLineType::StraightNoArrow, + }; + } + } + + LinkLineType::StraightNoArrow +} + /// shape 내부의 `` 자식 요소를 파싱하여 Fill을 반환한다. fn parse_shape_fill_brush(reader: &mut Reader<&[u8]>) -> Result { use crate::model::style::{FillType, GradientFill, ImageFill, ImageFillMode, SolidFill}; @@ -3358,9 +3380,10 @@ fn parse_shape_fill_brush(reader: &mut Reader<&[u8]>) -> Result b"mode" => { img.fill_mode = match attr_str(&attr).as_str() { "TILE" | "TILE_ALL" => ImageFillMode::TileAll, - "FIT" | "FIT_TO_SIZE" | "STRETCH" | "TOTAL" => { + "FIT" | "FIT_TO_SIZE" | "STRETCH" => { ImageFillMode::FitToSize } + "TOTAL" => ImageFillMode::Total, "CENTER" => ImageFillMode::Center, _ => ImageFillMode::TileAll, }; @@ -3560,6 +3583,12 @@ fn parse_shape_object( let mut e_end2 = crate::model::Point::default(); let object_ids = parse_object_element_attrs(e, &mut common, &mut shape_attr); + let connect_line_type = parse_connect_line_type_attr(e); + let mut connect_start_subject_id = 0_u32; + let mut connect_start_subject_index = 0_u32; + let mut connect_end_subject_id = 0_u32; + let mut connect_end_subject_index = 0_u32; + let mut connect_control_points = Vec::new(); let tag_name = String::from_utf8_lossy(shape_type).to_string(); let mut caption: Option = None; @@ -3674,6 +3703,8 @@ fn parse_shape_object( match attr.key.as_ref() { b"x" => x_coords[0] = parse_i32(&attr), b"y" => y_coords[0] = parse_i32(&attr), + b"subjectIDRef" => connect_start_subject_id = parse_u32(&attr), + b"subjectIdx" => connect_start_subject_index = parse_u32(&attr), _ => {} } } @@ -3683,10 +3714,24 @@ fn parse_shape_object( match attr.key.as_ref() { b"x" => x_coords[1] = parse_i32(&attr), b"y" => y_coords[1] = parse_i32(&attr), + b"subjectIDRef" => connect_end_subject_id = parse_u32(&attr), + b"subjectIdx" => connect_end_subject_index = parse_u32(&attr), _ => {} } } } + b"point" => { + let mut point = ConnectorControlPoint::default(); + for attr in ce.attributes().flatten() { + match attr.key.as_ref() { + b"x" => point.x = parse_i32(&attr), + b"y" => point.y = parse_i32(&attr), + b"type" => point.point_type = parse_u16(&attr), + _ => {} + } + } + connect_control_points.push(point); + } // [Task #1598] ellipse / arc 전용 지오메트리. x/y 속성만 읽어 Point 채움. b"center" => parse_xy(ce, &mut e_center), b"ax1" => parse_xy(ce, &mut e_axis1), @@ -3778,6 +3823,28 @@ fn parse_shape_object( }, ..Default::default() }), + b"connectLine" => ShapeObject::Line(LineShape { + common, + drawing, + start: crate::model::Point { + x: x_coords[0], + y: y_coords[0], + }, + end: crate::model::Point { + x: x_coords[1], + y: y_coords[1], + }, + connector: Some(ConnectorData { + link_type: connect_line_type, + start_subject_id: connect_start_subject_id, + start_subject_index: connect_start_subject_index, + end_subject_id: connect_end_subject_id, + end_subject_index: connect_end_subject_index, + control_points: connect_control_points, + raw_trailing: Vec::new(), + }), + ..Default::default() + }), b"arc" => ShapeObject::Arc(ArcShape { common, drawing, @@ -3861,7 +3928,8 @@ fn parse_container( children.push(ShapeObject::Picture(pic)); } } - b"rect" | b"ellipse" | b"line" | b"arc" | b"polygon" | b"curve" => { + b"rect" | b"ellipse" | b"line" | b"connectLine" | b"arc" | b"polygon" + | b"curve" => { // 자식 그리기 객체 let child = parse_shape_object(local, ce, reader)?; if let Control::Shape(shape) = child { @@ -6995,6 +7063,56 @@ mod tests { assert_eq!(master_page.raw_list_header.len(), 34); } + #[test] + fn test_parse_hwpx_connect_line_materializes_connector() { + let xml = r##" + + + + + + + + + + + + + + + + + + + +"##; + + let section = parse_hwpx_section(xml).unwrap(); + let Control::Shape(shape) = §ion.paragraphs[0].controls[0] else { + panic!("expected shape control"); + }; + let ShapeObject::Line(line) = shape.as_ref() else { + panic!("expected line shape"); + }; + + assert_eq!(line.common.instance_id, 1522096658); + assert_eq!(line.common.horizontal_offset, 45538); + assert_eq!(line.common.vertical_offset, 25812); + assert_eq!(line.start.x, 0); + assert_eq!(line.end.x, 1257); + + let connector = line.connector.as_ref().expect("connector data"); + assert_eq!(connector.link_type, LinkLineType::StraightOneWay); + assert_eq!(connector.start_subject_id, 11); + assert_eq!(connector.start_subject_index, 2); + assert_eq!(connector.end_subject_id, 22); + assert_eq!(connector.end_subject_index, 3); + assert_eq!(connector.control_points.len(), 2); + assert_eq!(connector.control_points[1].x, 100); + assert_eq!(connector.control_points[1].point_type, 26); + } + #[test] fn test_parse_rect_ratio_as_round_rate() { let xml = r#" diff --git a/src/renderer/canvaskit_policy.rs b/src/renderer/canvaskit_policy.rs index 3a9fa6dd7..63383b9e3 100644 --- a/src/renderer/canvaskit_policy.rs +++ b/src/renderer/canvaskit_policy.rs @@ -737,6 +737,7 @@ fn image_fill_mode_detail(value: ImageFillMode) -> &'static str { ImageFillMode::TileVertLeft => "tileVertLeft", ImageFillMode::TileVertRight => "tileVertRight", ImageFillMode::FitToSize => "fitToSize", + ImageFillMode::Total => "total", ImageFillMode::Center => "center", ImageFillMode::CenterTop => "centerTop", ImageFillMode::CenterBottom => "centerBottom", diff --git a/src/renderer/composer.rs b/src/renderer/composer.rs index aeaa3d856..5e07c82dd 100644 --- a/src/renderer/composer.rs +++ b/src/renderer/composer.rs @@ -1665,6 +1665,12 @@ fn pua_enclosed_border_type(ch: char) -> Option { fn pua_plain_text_display(ch: char) -> Option<&'static str> { match ch as u32 { 0xF012B => Some("(인)"), + // 2025 행정업무운영 편람 p08 TOC bullet. Hancom PDF renders this + // private-use marker as a filled square bullet. + 0xF031C => Some("■"), + // 2025 행정업무운영 편람 p15 callout bullet. Hancom PDF renders this + // private-use marker as a filled right-pointing pointer, not tofu. + 0xF02FC => Some("►"), // [Task #1001] 한컴 변환본 (HWP3→HWP5) 의 글머리표 PUA. 한컴 viewer 는 // 빈 체크박스 모양으로 표시. "□" (U+25A1 WHITE SQUARE) 매핑. // 실제 sample16-hwp5 의 PUA codepoint 는 U+F03C5 (글자 분석 결과). diff --git a/src/renderer/composer/line_breaking.rs b/src/renderer/composer/line_breaking.rs index fe042dd7e..0f47c5f2a 100644 --- a/src/renderer/composer/line_breaking.rs +++ b/src/renderer/composer/line_breaking.rs @@ -222,9 +222,11 @@ pub(crate) fn tokenize_paragraph( continue; } - // 한글 어절 또는 글자 + // 한글 어절 또는 글자. + // HWPX breakNonLatinWord="KEEP_WORD" is preserved as attr1 bit 7, + // which resolves to korean_break_unit == 1. if is_hangul(ch) { - if korean_break_unit == 0 { + if korean_break_unit == 1 { // 어절 모드: 연속 한글 + 후행 금칙 문자를 하나의 토큰으로 let start = i; let mut max_fs = 0.0f64; @@ -577,6 +579,34 @@ fn to_hwp(px: f64) -> i32 { (px * 75.0) as i32 } +fn condense_space_savings_hwp(space_width_hwp: i32, condense_min_space: u8) -> i32 { + if condense_min_space == 0 || space_width_hwp <= 0 { + return 0; + } + let shrink_percent = condense_min_space.min(75) as i32; + space_width_hwp * shrink_percent / 100 +} + +fn condensed_line_width_hwp(width_hwp: i32, space_savings_hwp: i32) -> i32 { + width_hwp - space_savings_hwp +} + +fn condense_fit_can_pull_next_token( + current_width_hwp: i32, + current_space_savings_hwp: i32, + effective_width_hwp: i32, + max_font_size: f64, +) -> bool { + let current_condensed_width = + condensed_line_width_hwp(current_width_hwp, current_space_savings_hwp); + let remaining_hwp = effective_width_hwp - current_condensed_width; + // Hancom uses condense to rescue a line that still has a meaningful + // natural gap, but it does not pull the next word into an already tight + // line. The p03 PDF preface is sensitive to that distinction. + let min_remaining_hwp = to_hwp((max_font_size * 2.5).max(20.0)); + remaining_hwp >= min_remaining_hwp +} + /// 토큰을 줄에 배치하는 Greedy 알고리즘 /// 한컴과 동일한 결과를 위해 HWPUNIT 정수로 폭을 누적한다. fn fill_lines( @@ -586,6 +616,7 @@ fn fill_lines( indent_px: f64, default_tab_width: f64, korean_break_unit: u8, + condense_min_space: u8, ) -> Vec { if tokens.is_empty() { return vec![LineBreakResult { @@ -609,12 +640,14 @@ fn fill_lines( let mut results = Vec::new(); let mut line_start_idx = 0usize; let mut lw = 0i32; // HWPUNIT 정수 누적 + let mut line_space_savings = 0i32; let mut line_max_fs = 0.0f64; let mut is_first_line = true; let mut last_break_token_idx: Option = None; let mut last_break_char_idx: usize = 0; let mut width_at_last_break = 0i32; + let mut space_savings_at_last_break = 0i32; let mut fs_at_last_break = 0.0f64; let eff_w = |first: bool| -> i32 { @@ -646,6 +679,7 @@ fn fill_lines( }); line_start_idx = *idx + 1; lw = 0; + line_space_savings = 0; line_max_fs = 0.0; is_first_line = false; last_break_token_idx = None; @@ -669,6 +703,7 @@ fn fill_lines( }); line_start_idx = last_break_char_idx; lw = lw - width_at_last_break; + line_space_savings -= space_savings_at_last_break; } else { results.push(LineBreakResult { start_idx: line_start_idx, @@ -678,6 +713,7 @@ fn fill_lines( }); line_start_idx = *idx; lw = 0; + line_space_savings = 0; line_max_fs = *max_font_size; } is_first_line = false; @@ -689,6 +725,7 @@ fn fill_lines( last_break_token_idx = Some(ti); last_break_char_idx = *idx; width_at_last_break = lw; + space_savings_at_last_break = line_space_savings; fs_at_last_break = line_max_fs; lw = next_tab_hwp; } @@ -704,8 +741,11 @@ fn fill_lines( last_break_token_idx = Some(ti); last_break_char_idx = *idx; width_at_last_break = lw; + space_savings_at_last_break = line_space_savings; fs_at_last_break = line_max_fs; - lw += to_hwp(*width); + let space_hwp = to_hwp(*width); + lw += space_hwp; + line_space_savings += condense_space_savings_hwp(space_hwp, condense_min_space); } BreakToken::Text { start_idx, @@ -726,22 +766,43 @@ fn fill_lines( if *end_idx - *start_idx == 1 && *start_idx > line_start_idx { let c = text_chars[*start_idx]; let allow_break = if is_hangul(c) { - korean_break_unit == 1 + korean_break_unit == 0 } else { is_cjk_ideograph(c) }; + let candidate_w = lw + w_hwp; // 이 글자가 줄에 들어가는 경우에만 break point 갱신 - if allow_break && lw + w_hwp <= eff_w(is_first_line) + LINE_BREAK_TOLERANCE { + if allow_break + && condensed_line_width_hwp(candidate_w, line_space_savings) + <= eff_w(is_first_line) + LINE_BREAK_TOLERANCE + { last_break_token_idx = Some(ti); last_break_char_idx = *end_idx; // 이 글자 다음 (이 글자 포함) - width_at_last_break = lw + w_hwp; // 이 글자 폭 포함 + width_at_last_break = candidate_w; // 이 글자 폭 포함 + space_savings_at_last_break = line_space_savings; fs_at_last_break = line_max_fs; } } // 한컴은 HWPUNIT 정수 양자화 시 미세한 반올림 차이를 허용 // 12 HU(~0.17mm) 이내의 초과는 줄에 포함 (경험적 허용 오차) const LINE_BREAK_TOLERANCE: i32 = 15; - if lw + w_hwp > eff_w(is_first_line) + LINE_BREAK_TOLERANCE { + let effective_width = eff_w(is_first_line); + let natural_candidate = lw + w_hwp; + let condensed_candidate = + condensed_line_width_hwp(natural_candidate, line_space_savings); + let needs_condense_to_fit = natural_candidate + > effective_width + LINE_BREAK_TOLERANCE + && condensed_candidate <= effective_width + LINE_BREAK_TOLERANCE; + let condense_pull_allowed = !needs_condense_to_fit + || condense_fit_can_pull_next_token( + lw, + line_space_savings, + effective_width, + *max_font_size, + ); + if condensed_candidate > effective_width + LINE_BREAK_TOLERANCE + || !condense_pull_allowed + { if *start_idx > line_start_idx { if let Some(_) = last_break_token_idx { results.push(LineBreakResult { @@ -756,6 +817,12 @@ fn fill_lines( } line_start_idx = next_start; lw = recalc_width_hwp(tokens, ti, next_start); + line_space_savings = recalc_space_savings_hwp( + tokens, + ti, + next_start, + condense_min_space, + ); lw += w_hwp; line_max_fs = *max_font_size; is_first_line = false; @@ -782,6 +849,7 @@ fn fill_lines( is_first_line = false; } lw = remaining_w; + line_space_savings = 0; line_max_fs = remaining_fs; last_break_token_idx = None; continue; @@ -842,6 +910,30 @@ fn recalc_width_hwp(tokens: &[BreakToken], current_token_idx: usize, new_line_st w } +/// 줄 바꿈 지점 이후 공백 압축 가능 폭 재계산 (HWPUNIT) +fn recalc_space_savings_hwp( + tokens: &[BreakToken], + current_token_idx: usize, + new_line_start: usize, + condense_min_space: u8, +) -> i32 { + let mut w = 0i32; + for t in &tokens[..current_token_idx] { + match t { + BreakToken::Space { + idx, + width, + max_font_size, + } if *idx >= new_line_start => { + let space_hwp = to_hwp(*width); + w += condense_space_savings_hwp(space_hwp, condense_min_space); + } + _ => {} + } + } + w +} + /// 긴 단어 폴백: 글자 단위 분할 (HWPUNIT) /// char_widths_hwp: 토큰 내 각 글자의 HWPUNIT 폭 (None이면 휴리스틱) fn char_level_break_hwp( @@ -1083,6 +1175,7 @@ pub(crate) fn reflow_line_segs( let indent_px = para_style.map(|s| s.indent).unwrap_or(0.0); let english_break_unit = para_style.map(|s| s.english_break_unit).unwrap_or(0); let korean_break_unit = para_style.map(|s| s.korean_break_unit).unwrap_or(0); + let condense_min_space = para_style.map(|s| s.condense_min_space).unwrap_or(0); let tab_width = para_style.map(|s| s.default_tab_width).unwrap_or(0.0); // 토큰화 → 줄 채움 → LineSeg 생성 @@ -1101,8 +1194,8 @@ pub(crate) fn reflow_line_segs( indent_px, tab_width, korean_break_unit, + condense_min_space, ); - let mut new_line_segs: Vec = Vec::new(); for lb in &line_breaks { let utf16_start = if new_line_segs.is_empty() { diff --git a/src/renderer/composer/tests.rs b/src/renderer/composer/tests.rs index c7226e12d..56e1d0f02 100644 --- a/src/renderer/composer/tests.rs +++ b/src/renderer/composer/tests.rs @@ -759,6 +759,32 @@ fn test_reflow_english_word_wrap() { assert_eq!(para.line_segs[1].text_start, 6); // "World" 시작 } +#[test] +fn test_reflow_condense_shrinks_measured_space_width() { + let mut styles = make_styles_with_font_size(10.0); + styles.para_styles[0].condense_min_space = 20; + + let mut para = Paragraph { + text: "A B ABCDEF".to_string(), + char_offsets: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + char_count: 10, + char_shapes: vec![CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }], + line_segs: vec![LineSeg { + text_start: 0, + ..Default::default() + }], + ..Default::default() + }; + + // Natural width is 50px: 8 latin chars at 5px + 2 spaces at 5px. + // condense=20 allows each measured space to shrink by 20%, saving 2px. + reflow_line_segs(&mut para, 48.0, &styles, 96.0); + assert_eq!(para.line_segs.len(), 1); +} + /// 강제 줄 바꿈: \n에서 즉시 줄 바꿈 #[test] fn test_reflow_forced_line_break() { @@ -816,7 +842,7 @@ fn test_tokenize_korean_eojeol() { char_shape_id: 0, }]; - let tokens = tokenize_paragraph(&text, &offsets, &shapes, &styles, 0, 0); + let tokens = tokenize_paragraph(&text, &offsets, &shapes, &styles, 0, 1); // "가나" (Text) + " " (Space) + "다라" (Text) = 3 tokens assert_eq!(tokens.len(), 3); assert!(matches!( @@ -838,6 +864,37 @@ fn test_tokenize_korean_eojeol() { )); } +/// 토크나이저: 한국어 글자 단위 토큰화 +#[test] +fn test_tokenize_korean_break_word_chars() { + let styles = make_styles_with_font_size(16.0); + let text: Vec = "가나".chars().collect(); + let offsets: Vec = (0..text.len() as u32).collect(); + let shapes = vec![CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }]; + + let tokens = tokenize_paragraph(&text, &offsets, &shapes, &styles, 0, 0); + assert_eq!(tokens.len(), 2); + assert!(matches!( + tokens[0], + BreakToken::Text { + start_idx: 0, + end_idx: 1, + .. + } + )); + assert!(matches!( + tokens[1], + BreakToken::Text { + start_idx: 1, + end_idx: 2, + .. + } + )); +} + /// 토크나이저: 영어 단어 토큰화 #[test] fn test_tokenize_english_words() { diff --git a/src/renderer/html.rs b/src/renderer/html.rs index 7da2ff5f2..905be90db 100644 --- a/src/renderer/html.rs +++ b/src/renderer/html.rs @@ -5,7 +5,10 @@ use super::layout::compute_char_positions; use super::render_tree::{PageRenderTree, RenderNode, RenderNodeType}; -use super::svg::{convert_wmf_to_svg, detect_image_mime_type}; +use super::svg::{ + bmp_bytes_to_png_bytes, convert_wmf_to_svg, detect_image_mime_type, pcx_bytes_to_png_bytes, + tiff_bytes_to_png_bytes, +}; use super::{LineStyle, PathCommand, Renderer, ShapeStyle, TextStyle}; use crate::model::style::UnderlineType; use base64::Engine; @@ -306,10 +309,8 @@ impl Renderer for HtmlRenderer { x, draw_y, font_family, draw_size, color, ); - if style.is_visually_bold() { - css.push_str("font-weight:bold;"); - } else if style.is_medium_weight() { - css.push_str("font-weight:500;"); + if let Some(weight) = style.css_font_weight() { + css.push_str(&format!("font-weight:{};", weight)); } if style.italic { css.push_str("font-style:italic;"); @@ -462,6 +463,21 @@ impl Renderer for HtmlRenderer { Some(svg_bytes) => (std::borrow::Cow::Owned(svg_bytes), "image/svg+xml"), None => (std::borrow::Cow::Borrowed(data), mime_type), } + } else if mime_type == "image/bmp" { + match bmp_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } + } else if mime_type == "image/x-pcx" { + match pcx_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } + } else if mime_type == "image/tiff" { + match tiff_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } } else { (std::borrow::Cow::Borrowed(data), mime_type) }; diff --git a/src/renderer/image_resolver.rs b/src/renderer/image_resolver.rs index 75ca60af7..0de762efa 100644 --- a/src/renderer/image_resolver.rs +++ b/src/renderer/image_resolver.rs @@ -27,6 +27,12 @@ pub(crate) fn resolve_image_payload(image: &ImageNode) -> Option tiff_bytes_to_png_bytes(data).map(|data| ResolvedImagePayload { + data, + mime: "image/png", + kind: ResolvedImageKind::FormatConverted, + suppress_effects: false, + }), "image/jpeg" if is_watermark_image(image) => { watermark_jpeg_bytes_to_hancom_baked_png_bytes(data).map(|data| ResolvedImagePayload { data, @@ -35,6 +41,12 @@ pub(crate) fn resolve_image_payload(image: &ImageNode) -> Option grayscale_jpeg_bytes_to_png_bytes(data).map(|data| ResolvedImagePayload { + data, + mime: "image/png", + kind: ResolvedImageKind::FormatConverted, + suppress_effects: false, + }), _ => None, } } @@ -73,6 +85,69 @@ pub(crate) fn bmp_bytes_to_png_bytes(data: &[u8]) -> Option> { Some(out) } +/// TIFF 바이트를 PNG 바이트로 재인코딩한다. 실패 시 None 반환. +/// +/// 브라우저와 rsvg는 SVG `` 내부의 `data:image/tiff` URI를 안정적으로 +/// 렌더링하지 못하므로, SVG/Canvas/HTML 임베딩 전에 PNG로 변환한다. +pub(crate) fn tiff_bytes_to_png_bytes(data: &[u8]) -> Option> { + use image::{load_from_memory_with_format, ImageFormat}; + + let img = load_from_memory_with_format(data, ImageFormat::Tiff).ok()?; + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .ok()?; + Some(out) +} + +/// Browser SVG/Canvas decoders can expose stale color planes in old Photoshop +/// grayscale JPEGs. Re-encode only visually gray JPEGs to PNG so color photos +/// keep the compact JPEG path. +pub(crate) fn grayscale_jpeg_bytes_to_png_bytes(data: &[u8]) -> Option> { + use image::{load_from_memory_with_format, ImageFormat}; + + if detect_image_mime_type(data) != "image/jpeg" { + return None; + } + + let mut img = load_from_memory_with_format(data, ImageFormat::Jpeg) + .ok()? + .to_rgba8(); + if img.width() == 0 || img.height() == 0 { + return None; + } + + let has_photoshop_profile = data + .windows(b"Adobe Photoshop".len()) + .any(|chunk| chunk == b"Adobe Photoshop") + || data + .windows(b"Adobe_CM".len()) + .any(|chunk| chunk == b"Adobe_CM"); + let is_gray = img.pixels().all(|px| { + let [r, g, b, _] = px.0; + let min = r.min(g).min(b); + let max = r.max(g).max(b); + max.saturating_sub(min) <= 2 + }); + let is_luma_plane_gray = has_photoshop_profile + && img.pixels().all(|px| { + let [_, g, b, _] = px.0; + g.abs_diff(128) <= 2 && b.abs_diff(128) <= 2 + }); + if is_luma_plane_gray { + for px in img.pixels_mut() { + let gray = px.0[0]; + px.0 = [gray, gray, gray, px.0[3]]; + } + } else if !is_gray { + return None; + } + + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .ok()?; + Some(out) +} + /// PCX 바이트를 PNG 바이트로 재인코딩한다. 실패 시 None 반환. /// /// 브라우저는 PCX 포맷을 native 렌더링하지 못하므로 (구형 ZSoft Paintbrush 포맷), @@ -358,3 +433,49 @@ pub(crate) fn detect_image_mime_type(data: &[u8]) -> &'static str { } "application/octet-stream" } + +#[cfg(test)] +mod tests { + use super::grayscale_jpeg_bytes_to_png_bytes; + use image::{DynamicImage, ImageFormat, Rgb, RgbImage}; + use std::io::Cursor; + + fn jpeg_from_pixels(width: u32, height: u32, pixels: impl Fn(u32, u32) -> [u8; 3]) -> Vec { + let mut img = RgbImage::new(width, height); + for y in 0..height { + for x in 0..width { + img.put_pixel(x, y, Rgb(pixels(x, y))); + } + } + + let mut out = Vec::new(); + DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut out), ImageFormat::Jpeg) + .expect("encode jpeg"); + out + } + + #[test] + fn grayscale_jpeg_is_normalized_to_png() { + let jpeg = jpeg_from_pixels(2, 2, |x, y| { + let g = 180 + (x + y) as u8; + [g, g, g] + }); + + let png = grayscale_jpeg_bytes_to_png_bytes(&jpeg).expect("gray jpeg should normalize"); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + #[test] + fn color_jpeg_keeps_jpeg_path() { + let jpeg = jpeg_from_pixels(2, 2, |x, y| { + if (x + y) % 2 == 0 { + [220, 64, 64] + } else { + [64, 120, 220] + } + }); + + assert!(grayscale_jpeg_bytes_to_png_bytes(&jpeg).is_none()); + } +} diff --git a/src/renderer/layout.rs b/src/renderer/layout.rs index 5e6cf44a4..81e056c35 100644 --- a/src/renderer/layout.rs +++ b/src/renderer/layout.rs @@ -975,8 +975,8 @@ mod utils; pub(crate) use paragraph_layout::ensure_min_baseline; pub(crate) use text_measurement::{ compute_char_positions, estimate_text_width, estimate_text_width_unrounded, - extract_tab_leaders_with_extended, find_next_tab_stop, is_cjk_char, resolved_to_text_style, - split_into_clusters, + extract_tab_leaders_with_extended, find_next_tab_stop, font_family_has_metrics, is_cjk_char, + resolved_to_text_style, split_into_clusters, }; // [Task #826] map_pua_bullet_char 는 통합 테스트 (tests/issue_826.rs) 에서 직접 검증 // (PUA substitution 매핑 정합) — pub 노출. @@ -1065,6 +1065,105 @@ impl LayoutEngine { ) } + fn render_layer_from_control( + control: &Control, + para_index: usize, + control_index: usize, + ) -> Option { + match control { + Control::Shape(shape) => Some(Self::render_layer_from_common( + shape.common(), + para_index, + control_index, + )), + Control::Picture(picture) => Some(Self::render_layer_from_common( + &picture.common, + para_index, + control_index, + )), + Control::Table(table) => Some(Self::render_layer_from_common( + &table.common, + para_index, + control_index, + )), + Control::Equation(equation) => Some(Self::render_layer_from_common( + &equation.common, + para_index, + control_index, + )), + _ => None, + } + } + + fn control_common_attr(control: &Control) -> Option<&CommonObjAttr> { + match control { + Control::Shape(shape) => Some(shape.common()), + Control::Picture(picture) => Some(&picture.common), + Control::Table(table) => Some(&table.common), + Control::Equation(equation) => Some(&equation.common), + _ => None, + } + } + + fn master_background_common_attr(control: &Control) -> Option<&CommonObjAttr> { + match control { + Control::Shape(shape) => Some(shape.common()), + Control::Picture(picture) => Some(&picture.common), + _ => None, + } + } + + fn render_layer_from_master_control( + &self, + control: &Control, + para_index: usize, + control_index: usize, + paper_area: &LayoutRect, + body_area: &LayoutRect, + ) -> Option { + let common = Self::control_common_attr(control)?; + let mut layer = Self::render_layer_from_common(common, para_index, control_index); + if Self::master_background_common_attr(control).is_some_and(|common| { + self.is_master_paper_background_control(common, paper_area, body_area) + }) { + layer.text_wrap = Some(TextWrap::BehindText); + } + Some(layer) + } + + fn is_master_paper_background_control( + &self, + common: &CommonObjAttr, + paper_area: &LayoutRect, + body_area: &LayoutRect, + ) -> bool { + if !matches!(common.text_wrap, TextWrap::InFrontOfText) { + return false; + } + if !matches!(common.horz_rel_to, HorzRelTo::Paper) + || !matches!(common.vert_rel_to, VertRelTo::Paper) + { + return false; + } + + let (width, height) = self.resolve_object_size(common, paper_area, body_area, paper_area); + let (x, y) = self.compute_object_position( + common, + width, + height, + paper_area, + paper_area, + body_area, + paper_area, + paper_area.y, + Alignment::Left, + ); + + let near_origin = (x - paper_area.x).abs() <= 1.0 && (y - paper_area.y).abs() <= 1.0; + let covers_paper = width >= paper_area.width * 0.95 && height >= paper_area.height * 0.95; + near_origin && covers_paper + } + fn push_layered_paper_children( paper_images: &mut Vec, temp_parent: &mut RenderNode, @@ -1535,6 +1634,8 @@ impl LayoutEngine { _composed: &[ComposedParagraph], styles: &ResolvedStyleSet, area: &LayoutRect, + body_area: &LayoutRect, + paper_area: &LayoutRect, table_area: Option<&LayoutRect>, page_index: u32, page_number: u32, @@ -1618,27 +1719,44 @@ impl LayoutEngine { // 머리말/꼬리말 내 Picture: header/footer area 기준 배치 for (ci, ctrl) in para.controls.iter().enumerate() { if let Control::Picture(pic) = ctrl { - let pic_container = LayoutRect { - x: area.x, - y: y_offset, - width: area.width, - height: area.height - (y_offset - area.y), - }; - // [Task #825] inner para_index = i (hf_paragraphs 안 인덱스), - // inner control_index = ci. outer 위치는 outer_hf_ref 보존. - self.layout_picture_full( - tree, - area_node, - pic, - &pic_container, - bin_data_content, - Alignment::Left, - outer_section_index, - Some(i), - Some(ci), - outer_hf_ref.clone(), - None, // [Task #1151 v4] cell_ctx: 머리말/꼬리말 path - ); + if pic.common.treat_as_char { + let pic_container = LayoutRect { + x: area.x, + y: y_offset, + width: area.width, + height: area.height - (y_offset - area.y), + }; + // [Task #825] inner para_index = i (hf_paragraphs 안 인덱스), + // inner control_index = ci. outer 위치는 outer_hf_ref 보존. + self.layout_picture_full( + tree, + area_node, + pic, + &pic_container, + bin_data_content, + Alignment::Left, + outer_section_index, + Some(i), + Some(ci), + outer_hf_ref.clone(), + None, // [Task #1151 v4] cell_ctx: 머리말/꼬리말 path + ); + } else { + self.layout_header_footer_picture( + tree, + area_node, + pic, + area, + body_area, + paper_area, + y_offset, + bin_data_content, + outer_section_index, + i, + ci, + outer_hf_ref.clone(), + ); + } let pic_h = hwpunit_to_px(pic.common.height as i32, self.dpi); y_offset += pic_h; } @@ -1673,8 +1791,8 @@ impl LayoutEngine { 0, // section_index styles, area, - area, - area, + body_area, + paper_area, y_offset, Alignment::Left, bin_data_content, @@ -2207,20 +2325,35 @@ impl LayoutEngine { let has_controls = !para.controls.is_empty(); if has_controls { for (ci, ctrl) in para.controls.iter().enumerate() { + let layer = self.render_layer_from_master_control( + ctrl, + pi, + ci, + &paper_area, + body_area, + ); + let mut temp_parent = layer.map(|_| { + RenderNode::new( + tree.next_id(), + RenderNodeType::MasterPage, + layout_rect_to_bbox(&paper_area), + ) + }); + let target_node = temp_parent.as_mut().unwrap_or(&mut mp_node); match ctrl { Control::Shape(_) | Control::Equation(_) => { self.layout_shape( tree, - &mut mp_node, + target_node, &mp.paragraphs, pi, ci, section_index, styles, - body_area, + &paper_area, body_area, &paper_area, - body_area.y, + paper_area.y, Alignment::Left, bin_data_content, &std::collections::HashMap::new(), @@ -2230,7 +2363,7 @@ impl LayoutEngine { Control::Picture(pic) => { let (pic_w, pic_h) = self.resolve_object_size( &pic.common, - body_area, + &paper_area, body_area, &paper_area, ); @@ -2238,11 +2371,11 @@ impl LayoutEngine { &pic.common, pic_w, pic_h, - body_area, - body_area, + &paper_area, + &paper_area, body_area, &paper_area, - body_area.y, + paper_area.y, Alignment::Left, ); let pic_area = super::layout::LayoutRect { @@ -2251,10 +2384,17 @@ impl LayoutEngine { width: pic_w, height: pic_h, }; + let mut positioned = (**pic).clone(); + positioned.common.horizontal_offset = 0; + positioned.common.vertical_offset = 0; + positioned.common.horz_rel_to = HorzRelTo::Para; + positioned.common.vert_rel_to = VertRelTo::Para; + positioned.common.horz_align = HorzAlign::Left; + positioned.common.vert_align = VertAlign::Top; self.layout_picture( tree, - &mut mp_node, - pic, + target_node, + &positioned, &pic_area, bin_data_content, Alignment::Left, @@ -2274,7 +2414,7 @@ impl LayoutEngine { // current_body_area를 통해 본문 영역으로 계산된다. self.layout_table( tree, - &mut mp_node, + target_node, t, section_index, styles, @@ -2298,6 +2438,14 @@ impl LayoutEngine { } _ => {} } + if let (Some(layer), Some(temp_parent)) = (layer, temp_parent.as_mut()) + { + Self::push_layered_paper_children( + &mut mp_node.children, + temp_parent, + layer, + ); + } } } else if !para.text.is_empty() { // 컨트롤 없는 텍스트 문단: vpos 기반 y 위치 사용 @@ -2338,12 +2486,95 @@ impl LayoutEngine { } } } + // Hancom prepares master-page furniture through object order, not raw XML + // order: text-wrap plane, zOrder, then stable source index. + Self::sort_paper_render_nodes(&mut mp_node.children); tree.root.children.push(mp_node); self.current_page_number.set(previous_page_number); } } } + #[allow(clippy::too_many_arguments)] + fn layout_header_footer_picture( + &self, + tree: &mut PageRenderTree, + area_node: &mut RenderNode, + pic: &crate::model::image::Picture, + area: &LayoutRect, + body_area: &LayoutRect, + paper_area: &LayoutRect, + para_y: f64, + bin_data_content: &[BinDataContent], + outer_section_index: Option, + inner_para_index: usize, + inner_control_index: usize, + outer_hf_ref: Option, + ) { + let rotation = pic.shape_attr.rotation_angle.rem_euclid(360); + let uses_rotated_frame = rotation != 0 + && pic.shape_attr.current_width > 0 + && pic.shape_attr.current_height > 0 + && pic.common.width > 0 + && pic.common.height > 0; + let (pic_width_hu, pic_height_hu) = if uses_rotated_frame { + ( + pic.shape_attr.current_width as i32, + pic.shape_attr.current_height as i32, + ) + } else { + picture_display_size_hu(pic) + }; + let frame_width = if uses_rotated_frame { + hwpunit_to_px(pic.common.width as i32, self.dpi) + } else { + hwpunit_to_px(pic_width_hu, self.dpi) + }; + let frame_height = if uses_rotated_frame { + hwpunit_to_px(pic.common.height as i32, self.dpi) + } else { + hwpunit_to_px(pic_height_hu, self.dpi) + }; + + let (frame_x, frame_y) = self.compute_object_position( + &pic.common, + frame_width, + frame_height, + area, + area, + body_area, + paper_area, + para_y, + Alignment::Left, + ); + let mut positioned = pic.clone(); + positioned.common.horizontal_offset = 0; + positioned.common.vertical_offset = 0; + positioned.common.horz_rel_to = HorzRelTo::Para; + positioned.common.vert_rel_to = VertRelTo::Para; + positioned.common.horz_align = HorzAlign::Left; + positioned.common.vert_align = VertAlign::Top; + let pic_container = LayoutRect { + x: frame_x, + y: frame_y, + width: frame_width, + height: frame_height, + }; + self.layout_picture_full( + tree, + area_node, + &positioned, + &pic_container, + bin_data_content, + Alignment::Left, + outer_section_index, + Some(inner_para_index), + Some(inner_control_index), + outer_hf_ref, + None, + ); + } + /// 머리말 영역 노드를 생성하여 tree에 추가한다. fn build_header( &self, @@ -2388,6 +2619,13 @@ impl LayoutEngine { composed, styles, &layout.header_area, + &layout.body_area, + &LayoutRect { + x: 0.0, + y: 0.0, + width: layout.page_width, + height: layout.page_height, + }, header_table_area.as_ref(), page_content.page_index, page_content.page_number, @@ -2514,6 +2752,13 @@ impl LayoutEngine { composed, styles, &layout.footer_area, + &layout.body_area, + &LayoutRect { + x: 0.0, + y: 0.0, + width: layout.page_width, + height: layout.page_height, + }, None, page_content.page_index, page_content.page_number, @@ -2556,7 +2801,7 @@ impl LayoutEngine { layout: &PageLayoutInfo, ) { let mut footnote_layout = layout.clone(); - if !page_content.footnotes.is_empty() { + if !page_content.footnotes.is_empty() && footnote_layout.footnote_area.height <= 0.0 { let fn_height = self.estimate_footnote_area_height( &page_content.footnotes, paragraphs, diff --git a/src/renderer/layout/paragraph_layout.rs b/src/renderer/layout/paragraph_layout.rs index 98b2da3e1..baf3cf1d4 100644 --- a/src/renderer/layout/paragraph_layout.rs +++ b/src/renderer/layout/paragraph_layout.rs @@ -77,6 +77,44 @@ fn numbering_marker_text_style( } } +fn aligned_edge_space_visual_run<'a>( + text: &'a str, + style: &TextStyle, + alignment: Alignment, + x: f64, + char_start: usize, +) -> Option<(&'a str, f64, f64, usize)> { + if !matches!( + alignment, + Alignment::Center | Alignment::Right | Alignment::Distribute | Alignment::Split + ) { + return None; + } + + let leading_count = text.chars().take_while(|ch| *ch == ' ').count(); + if leading_count < 2 { + return None; + } + + let content = text.trim_start_matches(' '); + if content.is_empty() { + return None; + } + + let font_size = if style.font_size > 0.0 { + style.font_size + } else { + 12.0 + }; + let ratio = if style.ratio > 0.0 { style.ratio } else { 1.0 }; + let per_space = + (font_size * 0.3 * ratio + style.letter_spacing + style.extra_char_spacing).max(0.0); + let visual_x = x + per_space * leading_count as f64; + let visual_w = estimate_text_width(content, style); + + Some((content, visual_x, visual_w, char_start + leading_count)) +} + fn para_float_horz_intersects_column( common: &CommonObjAttr, width_hu: i32, @@ -1506,6 +1544,7 @@ impl LayoutEngine { let tab_width = para_style.map(|s| s.default_tab_width).unwrap_or(0.0); let tab_stops = para_style.map(|s| s.tab_stops.clone()).unwrap_or_default(); let auto_tab_right = para_style.map(|s| s.auto_tab_right).unwrap_or(false); + let condense_min_space = para_style.map(|s| s.condense_min_space).unwrap_or(0); // [Task #489] 비-TAC Picture/Shape with wrap=Square 보유 여부. // 한컴은 어울림 그림이 있는 paragraph 의 LINE_SEG.cs/sw 를 그림 너비만큼 좁혀 @@ -2589,15 +2628,19 @@ impl LayoutEngine { } else { // 양쪽 정렬: 단어 간격 분배 (또는 음수 슬랙 시 압축) let raw_ews = slack / interior_spaces as f64; - let space_base_w = estimate_text_width( - " ", - &resolved_to_text_style( - styles, - comp_line.runs[0].char_style_id, - comp_line.runs[0].lang_index, - ), + let space_style = resolved_to_text_style( + styles, + comp_line.runs[0].char_style_id, + comp_line.runs[0].lang_index, ); - let min_ews = -(space_base_w * 0.5); + let space_base_w = estimate_text_width(" ", &space_style); + let min_ews = if condense_min_space > 0 { + let shrink_percent = f64::from(condense_min_space.min(75)) / 100.0; + let min_space = space_base_w * (1.0 - shrink_percent); + min_space - space_base_w + } else { + -(space_base_w * 0.5) + }; (raw_ews.max(min_ews), 0.0, 0.0) } } else if total_char_count > 1 { @@ -3343,17 +3386,36 @@ impl LayoutEngine { if run_fn_markers.is_empty() { // 각주 없음: 기존 방식으로 전체 TextRun 생성 + let (render_text, render_x, render_w, render_char_start) = + if let Some((text, visual_x, visual_w, visual_char_start)) = + aligned_edge_space_visual_run( + &run.text, + &text_style, + alignment, + x, + char_offset, + ) + { + ( + text.to_string(), + visual_x, + visual_w, + Some(visual_char_start), + ) + } else { + (run.text.clone(), x, full_width, Some(char_offset)) + }; let run_id = tree.next_id(); let run_node = RenderNode::new( run_id, RenderNodeType::TextRun(TextRunNode { - text: run.text.clone(), + text: render_text, style: text_style, char_shape_id: Some(run.char_style_id), para_shape_id: Some(composed.para_style_id), section_index: Some(section_index), para_index: Some(para_index), - char_start: Some(char_offset), + char_start: render_char_start, cell_context: cell_ctx.clone(), is_para_end: is_last_run, is_line_break_end: is_line_break, @@ -3364,7 +3426,7 @@ impl LayoutEngine { baseline, field_marker: FieldMarkerType::None, }), - BoundingBox::new(x, y, full_width, line_height), + BoundingBox::new(render_x, y, render_w, line_height), ); line_node.children.push(run_node); } else { @@ -3517,6 +3579,7 @@ impl LayoutEngine { // shape_layout 이 inline_shape_position 을 보고 별도 패스에서 렌더하므로 중복되지 않는다. for &(tac_rel, tac_w, tac_ci) in &run_tacs { + let mut preceding_text_trailing_space_width = 0.0; // tac 앞 텍스트 세그먼트 렌더링 if seg_start < tac_rel { let seg_text: String = run_chars[seg_start..tac_rel].iter().collect(); @@ -3533,19 +3596,55 @@ impl LayoutEngine { ); } let seg_w = estimate_text_width(&seg_text, &seg_style); + if seg_text.chars().any(|ch| !ch.is_whitespace()) { + let trailing_spaces: String = seg_text + .chars() + .rev() + .take_while(|ch| *ch == ' ') + .collect::>() + .into_iter() + .rev() + .collect(); + if !trailing_spaces.is_empty() { + preceding_text_trailing_space_width = + estimate_text_width(&trailing_spaces, &seg_style); + } + } let seg_char_count = tac_rel - seg_start; + let ( + render_seg_text, + render_seg_x, + render_seg_w, + render_seg_char_start, + ) = if let Some((text, visual_x, visual_w, visual_char_start)) = + aligned_edge_space_visual_run( + &seg_text, + &seg_style, + alignment, + x, + sub_char_offset, + ) { + ( + text.to_string(), + visual_x, + visual_w, + Some(visual_char_start), + ) + } else { + (seg_text, x, seg_w, Some(sub_char_offset)) + }; { let sub_run_id = tree.next_id(); let sub_run_node = RenderNode::new( sub_run_id, RenderNodeType::TextRun(TextRunNode { - text: seg_text, + text: render_seg_text, style: seg_style, char_shape_id: Some(run.char_style_id), para_shape_id: Some(composed.para_style_id), section_index: Some(section_index), para_index: Some(para_index), - char_start: Some(sub_char_offset), + char_start: render_seg_char_start, cell_context: cell_ctx.clone(), is_para_end: false, is_line_break_end: false, @@ -3556,7 +3655,7 @@ impl LayoutEngine { baseline, field_marker: FieldMarkerType::None, }), - BoundingBox::new(x, y, seg_w, line_height), + BoundingBox::new(render_seg_x, y, render_seg_w, line_height), ); line_node.children.push(sub_run_node); } @@ -3822,6 +3921,17 @@ impl LayoutEngine { let om_bottom = hwpunit_to_px(t.outer_margin_bottom as i32, self.dpi); let table_y = (y + baseline + om_bottom - table_h).max(y); + let table_x = if cell_ctx.is_some() { + clamp_inline_tac_table_x_to_line( + x, + tac_w, + x_base, + available_width, + preceding_text_trailing_space_width, + ) + } else { + x + }; self.layout_table( tree, col_node, @@ -3839,7 +3949,7 @@ impl LayoutEngine { None, 0.0, 0.0, - Some(x), + Some(table_x), None, None, false, @@ -3851,7 +3961,7 @@ impl LayoutEngine { para_index, tac_ci, cell_ctx.as_ref(), - x, + table_x, table_y, ); } @@ -5857,9 +5967,70 @@ fn form_color_to_css(color: u32) -> String { format!("#{:02x}{:02x}{:02x}", r, g, b) } +fn clamp_inline_tac_table_x_to_line( + x: f64, + table_width: f64, + line_x: f64, + line_width: f64, + trailing_space_width: f64, +) -> f64 { + if !x.is_finite() + || !table_width.is_finite() + || !line_x.is_finite() + || !line_width.is_finite() + || !trailing_space_width.is_finite() + || table_width <= 0.0 + || line_width <= 0.0 + { + return x; + } + + let line_right = line_x + line_width; + let right_overflow = (x + table_width - line_right).max(0.0); + let trailing_adjustment = trailing_space_width.max(0.0).min(right_overflow * 0.64); + let effective_line_width = (line_width - trailing_adjustment).max(0.0); + let max_x = (line_x + effective_line_width - table_width).max(line_x); + x.clamp(line_x, max_x) +} + #[cfg(test)] mod pua_mapping_tests { - use super::map_pua_bullet_char; + use super::{ + aligned_edge_space_visual_run, clamp_inline_tac_table_x_to_line, map_pua_bullet_char, + }; + use crate::model::style::Alignment; + use crate::renderer::TextStyle; + + #[test] + fn clamps_cell_inline_table_to_line_right_edge() { + let clipped = clamp_inline_tac_table_x_to_line(546.5, 96.9, 186.1, 428.9, 0.0); + assert!((clipped - 518.1).abs() < 0.001); + + let clipped_before_trailing_spaces = + clamp_inline_tac_table_x_to_line(546.5, 96.9, 186.1, 428.9, 43.0); + assert!((clipped_before_trailing_spaces - 499.9).abs() < 0.1); + + let already_inside = clamp_inline_tac_table_x_to_line(224.5, 96.9, 186.1, 428.9, 43.0); + assert!((already_inside - 224.5).abs() < 0.001); + } + + #[test] + fn centered_edge_spaces_use_compact_visual_advance() { + let style = TextStyle { + font_size: 20.0, + ..Default::default() + }; + + let (text, x, _, char_start) = + aligned_edge_space_visual_run(" 제목", &style, Alignment::Center, 100.0, 7).unwrap(); + + assert_eq!(text, "제목"); + assert!((x - 112.0).abs() < 0.001); + assert_eq!(char_start, 9); + assert!( + aligned_edge_space_visual_run(" 제목", &style, Alignment::Left, 100.0, 7).is_none() + ); + } #[test] fn supplementary_pua_a_passthrough_for_boxed_digits() { diff --git a/src/renderer/layout/shape_layout.rs b/src/renderer/layout/shape_layout.rs index 709544765..e60fc841e 100644 --- a/src/renderer/layout/shape_layout.rs +++ b/src/renderer/layout/shape_layout.rs @@ -1,11 +1,11 @@ //! 도형/글상자/그룹 개체 레이아웃 -use super::super::composer::{compose_paragraph, ComposedParagraph}; +use super::super::composer::{compose_paragraph, reflow_line_segs, ComposedParagraph}; use super::super::page_layout::LayoutRect; use super::super::pagination::PageItem; use super::super::render_tree::*; use super::super::style_resolver::ResolvedStyleSet; -use super::super::{hwpunit_to_px, PathCommand, ShapeStyle, TextStyle}; +use super::super::{hwpunit_to_px, px_to_hwpunit, PathCommand, ShapeStyle, TextStyle}; use super::text_measurement::{ estimate_text_width, is_cjk_char, is_vertical_rotate_char, resolved_to_text_style, vertical_substitute_char, @@ -18,9 +18,64 @@ use super::{CellContext, CellPathEntry}; use crate::model::bin_data::BinDataContent; use crate::model::control::Control; use crate::model::paragraph::Paragraph; -use crate::model::shape::CommonObjAttr; +use crate::model::shape::{CommonObjAttr, DrawingObjAttr, TextBox}; use crate::model::shape::{HorzAlign, HorzRelTo, VertAlign, VertRelTo}; -use crate::model::style::Alignment; +use crate::model::style::{Alignment, FillType}; + +/// 글상자에 공백이 아닌 실제 텍스트가 한 글자라도 있는지. +fn textbox_has_visible_text(text_box: &TextBox) -> bool { + text_box + .paragraphs + .iter() + .any(|para| para.text.chars().any(|ch| !ch.is_whitespace())) +} + +fn textbox_contains_non_tac_picture(text_box: &TextBox) -> bool { + text_box.paragraphs.iter().any(|para| { + para.controls + .iter() + .any(|control| matches!(control, Control::Picture(pic) if !pic.common.treat_as_char)) + }) +} + +/// 평탄화된 HWPX 그룹(matrix group) 자식의 "글상자 보조선"(검정 얇은 SOLID 테두리)은 +/// 한컴 실물에서 인쇄되지 않는다(편람 장 표지 "행정업무 운영 개요" 제목/목록 글상자). +/// 오탐 방지를 위해 매우 좁게 한정: 그룹 자식(group_level>0) + 회전/전단 없음 + 검정 +/// (color==0) 얇은(0 bool { + if drawing.caption.is_some() { + return false; + } + let sa = &drawing.shape_attr; + let has_rotation_or_shear = sa.render_b.abs() > 1e-6 || sa.render_c.abs() > 1e-6; + if sa.group_level == 0 || has_rotation_or_shear { + return false; + } + let line = &drawing.border_line; + let line_type = line.attr & 0x3f; + if line_type != 1 || line.color != 0 || line.width <= 0 || line.width > 40 { + return false; + } + let text_only_box = drawing + .text_box + .as_ref() + .is_some_and(textbox_has_visible_text) + && drawing.fill.fill_type == FillType::None + && drawing.fill.gradient.is_none() + && drawing.fill.image.is_none(); + if text_only_box { + return true; + } + drawing.text_box.is_none() + && drawing.fill.fill_type == FillType::Solid + && drawing.fill.gradient.is_none() + && drawing.fill.image.is_none() + && drawing + .fill + .solid + .is_some_and(|solid| solid.background_color == 0x00ff_ffff && solid.pattern_type <= 0) +} fn push_placeholder_render_node( tree: &mut PageRenderTree, @@ -164,6 +219,133 @@ fn textbox_tac_space_advance_override( } } +fn matrix_textbox_lines_need_reflow(para: &Paragraph) -> bool { + para.controls.is_empty() + && !para.text.is_empty() + && !para.text.contains('\n') + && para.line_segs.len() > 1 +} + +fn matrix_textbox_lines_overflow_height(para: &Paragraph, available_height: f64, dpi: f64) -> bool { + para.line_segs.last().is_some_and(|seg| { + hwpunit_to_px(seg.vertical_pos.saturating_add(seg.line_height), dpi) + > available_height + 0.5 + }) +} + +fn should_reflow_matrix_textbox_lines( + matrix_positioned: bool, + drawing: &DrawingObjAttr, + text_box: &TextBox, + text_direction: u8, + available_width: f64, + available_height: f64, + dpi: f64, +) -> bool { + if text_direction != 0 || available_width <= 0.0 || available_height <= 0.0 { + return false; + } + + if !matrix_positioned { + return false; + } + + let sa = &drawing.shape_attr; + let has_axis_scale = + (sa.render_sx.abs() - 1.0).abs() > 0.001 || (sa.render_sy.abs() - 1.0).abs() > 0.001; + let has_rotation_or_shear = sa.render_b.abs() > 1e-6 || sa.render_c.abs() > 1e-6; + let compressed_group_child = sa.group_level > 0 + && sa.render_sx > 0.0 + && sa.render_sy > 0.0 + && (sa.render_sx < 0.99 || sa.render_sy < 0.99); + has_axis_scale + && !has_rotation_or_shear + && text_box.paragraphs.iter().any(|para| { + matrix_textbox_lines_need_reflow(para) + && (compressed_group_child + || matrix_textbox_lines_overflow_height(para, available_height, dpi)) + }) +} + +fn reflow_matrix_textbox_para( + para: &mut Paragraph, + available_width: f64, + _available_height: f64, + styles: &ResolvedStyleSet, + dpi: f64, +) { + if !matrix_textbox_lines_need_reflow(para) { + return; + } + + const MATRIX_TEXT_FIT_TOLERANCE_PX: f64 = 3.0; + let composed = compose_paragraph(para); + let text_len = para.text.chars().count(); + let full_width = measure_composed_text_range_width(&composed, styles, 0, text_len, None); + + if full_width <= available_width + MATRIX_TEXT_FIT_TOLERANCE_PX { + if let Some(first_seg) = para.line_segs.first().cloned() { + let mut line_seg = first_seg; + line_seg.text_start = 0; + line_seg.segment_width = px_to_hwpunit(available_width, dpi); + para.line_segs = vec![line_seg]; + return; + } + } + + reflow_line_segs(para, available_width, styles, dpi); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::paragraph::{CharShapeRef, LineSeg}; + use crate::renderer::style_resolver::{ResolvedCharStyle, ResolvedParaStyle}; + + fn line_seg(text_start: u32, vertical_pos: i32) -> LineSeg { + LineSeg { + text_start, + vertical_pos, + line_height: 2000, + text_height: 2000, + baseline_distance: 1700, + line_spacing: 1200, + segment_width: 16856, + tag: LineSeg::TAG_SINGLE_SEGMENT_LINE, + ..Default::default() + } + } + + #[test] + fn matrix_textbox_para_collapses_imported_lines_that_overflow_height() { + let mut para = Paragraph { + char_count: 2, + text: "AB".to_string(), + char_offsets: vec![0, 1], + char_shapes: vec![CharShapeRef { + start_pos: 0, + char_shape_id: 0, + }], + line_segs: vec![line_seg(0, 0), line_seg(1, 3200)], + ..Default::default() + }; + let mut styles = ResolvedStyleSet::default(); + styles.char_styles.push(ResolvedCharStyle { + font_family: "Arial".to_string(), + font_families: vec!["Arial".to_string(); 7], + font_size: 20.0, + ..Default::default() + }); + styles.para_styles.push(ResolvedParaStyle::default()); + + reflow_matrix_textbox_para(&mut para, 80.0, 30.0, &styles, 96.0); + + assert_eq!(para.line_segs.len(), 1); + assert_eq!(para.line_segs[0].text_start, 0); + assert_eq!(para.line_segs[0].segment_width, px_to_hwpunit(80.0, 96.0)); + } +} + impl LayoutEngine { pub(crate) fn scan_textbox_overflow( &self, @@ -382,8 +564,10 @@ impl LayoutEngine { let (mut shape_w, mut shape_h) = self.resolve_object_size(common, col_area, body_area, paper_area); - // current size가 common size보다 크면 current size 사용 - // (스케일 행렬이 적용된 글상자 등에서 common.height < current_height인 경우) + if shape + .drawing() + .and_then(|drawing| drawing.text_box.as_ref()) + .is_some_and(textbox_contains_non_tac_picture) { let sa = shape.shape_attr(); let cur_w = hwpunit_to_px(sa.current_width as i32, self.dpi); @@ -500,6 +684,7 @@ impl LayoutEngine { overflow_map, &[], None, // [Task #1138] 본문 도형 — 셀 정보 없음 + false, ); // 캡션 렌더링 @@ -549,7 +734,9 @@ impl LayoutEngine { } /// 회전이 있는 그룹 자식 도형을 전체 아핀 변환으로 렌더링한다. - /// group_x/y: 그룹의 페이지 절대 좌표 (px) + /// HWPX/HWP renderingInfo is already composed within the top-level group + /// coordinate system. Apply only that top-level group anchor; nested group + /// bboxes are structural and must not be applied as another translation. /// sa: 자식의 ShapeComponentAttr (아핀 행렬 포함) #[allow(clippy::too_many_arguments)] pub(crate) fn layout_group_child_affine( @@ -557,8 +744,8 @@ impl LayoutEngine { tree: &mut PageRenderTree, parent: &mut RenderNode, child: &crate::model::shape::ShapeObject, - group_x: f64, - group_y: f64, + group_origin_x: f64, + group_origin_y: f64, sa: &crate::model::shape::ShapeComponentAttr, section_index: usize, para_index: usize, @@ -578,13 +765,13 @@ impl LayoutEngine { let d = sa.render_sy; let ty = sa.render_ty; - // HWP 좌표를 아핀 변환 후 페이지 절대 좌표(px)로 변환하는 헬퍼 + // HWP 좌표를 아핀 변환 후 페이지 절대 좌표(px)로 변환하는 헬퍼. let transform_pt = |ox: f64, oy: f64| -> (f64, f64) { let gx = a * ox + b * oy + tx; let gy = c * ox + d * oy + ty; ( - group_x + hwpunit_to_px(gx as i32, self.dpi), - group_y + hwpunit_to_px(gy as i32, self.dpi), + group_origin_x + hwpunit_to_px(gx as i32, self.dpi), + group_origin_y + hwpunit_to_px(gy as i32, self.dpi), ) }; @@ -743,6 +930,7 @@ impl LayoutEngine { &empty_map, parent_cell_path, None, // [Task #1138] TODO: layout_group_child_affine 에 cell ctx propagate (별도 후속) + true, ); } } @@ -770,11 +958,63 @@ impl LayoutEngine { parent_cell_path: &[CellPathEntry], // [Task #1138] 표 셀 내 도형인 경우: (cell_idx, cell_para_idx, outer_table_ctrl_idx) table_cell_ref: Option<(usize, usize, usize)>, + // 그룹 자식은 renderingInfo 행렬로 이미 위치/대칭이 반영되어 있으므로 + // ShapeComponentAttr의 flip/rotation을 다시 SVG transform으로 적용하지 않는다. + matrix_positioned: bool, + ) { + self.layout_shape_object_with_group_origin( + tree, + parent, + shape, + base_x, + base_y, + w, + h, + section_index, + para_index, + control_index, + styles, + bin_data_content, + overflow_map, + parent_cell_path, + table_cell_ref, + matrix_positioned, + None, + ); + } + + #[allow(clippy::too_many_arguments)] + fn layout_shape_object_with_group_origin( + &self, + tree: &mut PageRenderTree, + parent: &mut RenderNode, + shape: &crate::model::shape::ShapeObject, + base_x: f64, + base_y: f64, + w: f64, + h: f64, + section_index: usize, + para_index: usize, + control_index: usize, + styles: &ResolvedStyleSet, + bin_data_content: &[BinDataContent], + overflow_map: &std::collections::HashMap<(usize, usize), Vec>, + parent_cell_path: &[CellPathEntry], + // [Task #1138] 표 셀 내 도형인 경우: (cell_idx, cell_para_idx, outer_table_ctrl_idx) + table_cell_ref: Option<(usize, usize, usize)>, + // 그룹 자식은 renderingInfo 행렬로 이미 위치/대칭이 반영되어 있으므로 + // ShapeComponentAttr의 flip/rotation을 다시 SVG transform으로 적용하지 않는다. + matrix_positioned: bool, + inherited_group_origin: Option<(f64, f64)>, ) { use crate::model::shape::ShapeObject; // 공통: 회전/대칭 정보 추출 - let transform = extract_shape_transform(shape.shape_attr()); + let transform = if matrix_positioned { + ShapeTransform::default() + } else { + extract_shape_transform(shape.shape_attr()) + }; // [Task #1138] 표 셀 내 도형 식별을 위한 cell 정보 추출 (helper) let cell_index = table_cell_ref.map(|(ci, _, _)| ci); @@ -801,7 +1041,12 @@ impl LayoutEngine { match shape { ShapeObject::Rectangle(rect) => { - let (style, gradient) = drawing_to_shape_style(&rect.drawing); + let (mut style, gradient) = drawing_to_shape_style(&rect.drawing); + // 평탄화된 그룹 자식 글상자의 비인쇄 보조선(검정 얇은 SOLID)을 억제한다. + if should_suppress_group_child_construction_stroke(&rect.drawing) { + style.stroke_color = None; + style.stroke_width = 0.0; + } let round_px = if rect.round_rate > 0 { (rect.round_rate as f64 / 100.0) * render_w.min(render_h) / 2.0 } else { @@ -850,6 +1095,7 @@ impl LayoutEngine { overflow_map, parent_cell_path, shape.common().treat_as_char, + matrix_positioned, ); parent.children.push(node); } @@ -895,7 +1141,31 @@ impl LayoutEngine { let conn_y1 = render_y + hwpunit_to_px(line.start.y, self.dpi) * sy; let conn_x2 = render_x + hwpunit_to_px(line.end.x, self.dpi) * sx; let conn_y2 = render_y + hwpunit_to_px(line.end.y, self.dpi) * sy; - commands.push(PathCommand::MoveTo(conn_x1, conn_y1)); + let connector_point_xy = + |cp: &crate::model::shape::ConnectorControlPoint| { + ( + render_x + hwpunit_to_px(cp.x, self.dpi) * sx, + render_y + hwpunit_to_px(cp.y, self.dpi) * sy, + ) + }; + let first_control_is_start = conn + .control_points + .first() + .map(|cp| cp.point_type == 3) + .unwrap_or(false); + let last_control_is_end = conn + .control_points + .last() + .map(|cp| cp.point_type == 26) + .unwrap_or(false); + let (path_start_x, path_start_y) = if first_control_is_start { + connector_point_xy(&conn.control_points[0]) + } else { + (conn_x1, conn_y1) + }; + let mut path_end_x = conn_x2; + let mut path_end_y = conn_y2; + commands.push(PathCommand::MoveTo(path_start_x, path_start_y)); if conn.link_type.is_arc() { // 곡선 연결선: 제어점(type=2)을 bezier 제어점으로, 나머지는 앵커로 사용 @@ -963,13 +1233,21 @@ impl LayoutEngine { } } } else { - // 꺽인 연결선: 제어점을 LineTo로 연결 - for cp in &conn.control_points { - let cpx = render_x + hwpunit_to_px(cp.x, self.dpi) * sx; - let cpy = render_y + hwpunit_to_px(cp.y, self.dpi) * sy; + for cp in conn.control_points.iter().skip(if first_control_is_start { + 1 + } else { + 0 + }) { + let (cpx, cpy) = connector_point_xy(cp); commands.push(PathCommand::LineTo(cpx, cpy)); + path_end_x = cpx; + path_end_y = cpy; + } + if !last_control_is_end { + commands.push(PathCommand::LineTo(conn_x2, conn_y2)); + path_end_x = conn_x2; + path_end_y = conn_y2; } - commands.push(PathCommand::LineTo(conn_x2, conn_y2)); } let style = ShapeStyle { @@ -989,7 +1267,8 @@ impl LayoutEngine { path_node.cell_para_index = cell_para_index; path_node.outer_table_control_index = outer_table_control_index; // 연결선: 시작/끝 좌표 (선 선택 방식용) + 화살표 - path_node.connector_endpoints = Some((conn_x1, conn_y1, conn_x2, conn_y2)); + path_node.connector_endpoints = + Some((path_start_x, path_start_y, path_end_x, path_end_y)); if line_style.start_arrow != super::super::ArrowStyle::None || line_style.end_arrow != super::super::ArrowStyle::None { @@ -1109,6 +1388,7 @@ impl LayoutEngine { &empty_map, parent_cell_path, shape.common().treat_as_char, + matrix_positioned, ); parent.children.push(node); } @@ -1287,6 +1567,7 @@ impl LayoutEngine { &empty_map, parent_cell_path, shape.common().treat_as_char, + matrix_positioned, ); parent.children.push(node); } @@ -1346,12 +1627,20 @@ impl LayoutEngine { &empty_map, parent_cell_path, shape.common().treat_as_char, + matrix_positioned, ); parent.children.push(node); } ShapeObject::Group(group) => { // 묶음 개체: Group 컨테이너 노드로 감싸서 hittest 시 하나의 개체로 선택되도록 함 let group_id = tree.next_id(); + let group_origin = inherited_group_origin.unwrap_or({ + if group.shape_attr.group_level == 0 { + (base_x, base_y) + } else { + (0.0, 0.0) + } + }); let mut group_node = RenderNode::new( group_id, RenderNodeType::Group(GroupNode { @@ -1361,19 +1650,6 @@ impl LayoutEngine { }), BoundingBox::new(base_x, base_y, w, h), ); - // 그룹 스케일 팩터: current_size / original_size (리사이즈 시 적용) - let gsa = &group.shape_attr; - let group_sx = if gsa.original_width > 0 { - gsa.current_width as f64 / gsa.original_width as f64 - } else { - 1.0 - }; - let group_sy = if gsa.original_height > 0 { - gsa.current_height as f64 / gsa.original_height as f64 - } else { - 1.0 - }; - for (_ci, child) in group.children.iter().enumerate() { let sa = child.shape_attr(); let has_rotation = sa.render_b.abs() > 1e-6 || sa.render_c.abs() > 1e-6; @@ -1383,8 +1659,8 @@ impl LayoutEngine { tree, &mut group_node, child, - base_x, - base_y, + group_origin.0, + group_origin.1, sa, section_index, para_index, @@ -1394,20 +1670,23 @@ impl LayoutEngine { parent_cell_path, ); } else { - // render_tx/ty와 render_sx/sy에는 이미 그룹 스케일이 반영되어 있으므로 - // group_sx/sy를 추가 적용하지 않음 - let child_x = base_x + hwpunit_to_px(sa.render_tx as i32, self.dpi); - let child_y = base_y + hwpunit_to_px(sa.render_ty as i32, self.dpi); - let child_w = hwpunit_to_px( - (sa.original_width as f64 * sa.render_sx.abs()) as i32, - self.dpi, - ); - let child_h = hwpunit_to_px( - (sa.original_height as f64 * sa.render_sy.abs()) as i32, - self.dpi, - ); + // render_tx/ty와 render_sx/sy에는 top-level group 로컬 좌표계 + // 안에서 그룹 스케일/이동/flip이 합성되어 있다. top-level group + // anchor만 더하고, nested group bbox는 다시 더하지 않는다. 음수 + // scale이면 tx는 오른쪽/아래쪽 모서리일 수 있으므로 두 변환 + // 모서리의 min/max로 실제 bbox를 만든다. + let x0 = sa.render_tx; + let y0 = sa.render_ty; + let x1 = sa.render_tx + sa.original_width as f64 * sa.render_sx; + let y1 = sa.render_ty + sa.original_height as f64 * sa.render_sy; + let child_x = + group_origin.0 + hwpunit_to_px(x0.min(x1).round() as i32, self.dpi); + let child_y = + group_origin.1 + hwpunit_to_px(y0.min(y1).round() as i32, self.dpi); + let child_w = hwpunit_to_px((x1 - x0).abs().round() as i32, self.dpi); + let child_h = hwpunit_to_px((y1 - y0).abs().round() as i32, self.dpi); let empty_map = std::collections::HashMap::new(); - self.layout_shape_object( + self.layout_shape_object_with_group_origin( tree, &mut group_node, child, @@ -1423,6 +1702,8 @@ impl LayoutEngine { &empty_map, parent_cell_path, table_cell_ref, // [Task #1138] 그룹 자식 — 부모와 같은 셀 컨텍스트 + true, + Some(group_origin), ); } } @@ -1753,6 +2034,7 @@ impl LayoutEngine { overflow_map: &std::collections::HashMap<(usize, usize), Vec>, parent_cell_path: &[CellPathEntry], parent_treat_as_char: bool, + matrix_positioned: bool, ) { let text_box = match &drawing.text_box { Some(tb) => tb, @@ -1841,6 +2123,35 @@ impl LayoutEngine { // (0=가로, 1=영문 눕힘, 2=영문 세움) // 주의: 테이블 셀은 bit 16~18이지만 글상자 LIST_HEADER는 bit 0~2 let text_direction = (text_box.list_attr & 0x07) as u8; + let reflowed_textbox_paragraphs = if should_reflow_matrix_textbox_lines( + matrix_positioned, + drawing, + text_box, + text_direction, + inner_area.width, + inner_area.height, + self.dpi, + ) { + let mut paragraphs = text_box.paragraphs.clone(); + for para in paragraphs + .iter_mut() + .filter(|para| matrix_textbox_lines_need_reflow(para)) + { + reflow_matrix_textbox_para( + para, + inner_area.width, + inner_area.height, + styles, + self.dpi, + ); + } + Some(paragraphs) + } else { + None + }; + let textbox_paragraphs = reflowed_textbox_paragraphs + .as_deref() + .unwrap_or(&text_box.paragraphs); // 빈 텍스트박스에 오버플로우 문단이 매핑되어 있는지 확인 (가로/세로 공통) let key = (para_index, control_index); @@ -1929,15 +2240,14 @@ impl LayoutEngine { // 오버플로우 감지 (가로/세로 공통): 텍스트박스 내 문단의 line_segs에서 // vpos가 리셋(이전 문단보다 감소)되고 sw가 변경되면 // 해당 문단은 다른 텍스트박스(연결된 글상자)에 속함 - let first_sw = text_box - .paragraphs + let first_sw = textbox_paragraphs .first() .and_then(|p| p.line_segs.first()) .map(|ls| ls.segment_width) .unwrap_or(0); let mut max_vpos_end: i32 = 0; let mut overflow_start_idx: Option = None; - for (pi, para) in text_box.paragraphs.iter().enumerate() { + for (pi, para) in textbox_paragraphs.iter().enumerate() { if let Some(first_ls) = para.line_segs.first() { let sw = first_ls.segment_width; let vpos = first_ls.vertical_pos; @@ -1957,14 +2267,14 @@ impl LayoutEngine { } // 현재 텍스트박스에 속하는 문단만 사용 - let para_count = overflow_start_idx.unwrap_or(text_box.paragraphs.len()); + let para_count = overflow_start_idx.unwrap_or(textbox_paragraphs.len()); // 세로쓰기: 오버플로우 감지 후 세로 레이아웃으로 분기 if text_direction != 0 { self.layout_vertical_textbox_text_with_paras( tree, &mut textbox_node, - &text_box.paragraphs[..para_count], + &textbox_paragraphs[..para_count], text_box, styles, &inner_area, @@ -1987,7 +2297,7 @@ impl LayoutEngine { return; } - let mut composed_paras: Vec<_> = text_box.paragraphs[..para_count] + let mut composed_paras: Vec<_> = textbox_paragraphs[..para_count] .iter() .map(|p| compose_paragraph(p)) .collect(); @@ -1995,7 +2305,7 @@ impl LayoutEngine { // AutoNumber(Page) 치환: 글상자 안의 쪽번호 필드를 현재 페이지 번호로 변환 let current_pn = self.current_page_number.get(); if current_pn > 0 { - for (pi, para) in text_box.paragraphs[..para_count].iter().enumerate() { + for (pi, para) in textbox_paragraphs[..para_count].iter().enumerate() { if para.controls.iter().any(|c| { matches!(c, crate::model::control::Control::AutoNumber(an) if an.number_type == crate::model::control::AutoNumberType::Page) @@ -2018,14 +2328,14 @@ impl LayoutEngine { // line_seg 높이만으로 CENTER 오프셋을 계산할 수 없다. 그렇게 하면 그림이 실제 // 콘텐츠 높이에서 빠지고, 아래 picture container가 오프셋 이후 남은 높이로 // 줄어들어 한컴보다 과도하게 축소된다. - let mut total_content_height = text_box.paragraphs[..para_count] + let mut total_content_height = textbox_paragraphs[..para_count] .iter() .flat_map(|p| p.line_segs.last()) .map(|ls| hwpunit_to_px(ls.vertical_pos + ls.line_height, self.dpi)) .last() .unwrap_or(0.0); - for para in &text_box.paragraphs[..para_count] { + for para in &textbox_paragraphs[..para_count] { let para_vpos = para .line_segs .first() @@ -2067,7 +2377,7 @@ impl LayoutEngine { // vpos 기반 수직 위치: 원본 HWP 파일에서는 vertical_pos가 누적 절대값, // 편집 후 reflow된 문단은 vertical_pos=0이므로 incremental para_y와 비교하여 // 더 큰 값 사용 (원본 호환 + 편집 후 정상 배치 모두 지원) - let para = &text_box.paragraphs[tb_para_idx]; + let para = &textbox_paragraphs[tb_para_idx]; if let Some(first_ls) = para.line_segs.first() { let vpos_y = inner_area.y + vert_offset + hwpunit_to_px(first_ls.vertical_pos, self.dpi); @@ -2132,7 +2442,7 @@ impl LayoutEngine { // 오버플로우된 문단은 제외 (다른 텍스트박스에서 처리) use crate::model::shape::ShapeObject; let mut inline_y = inner_area.y + vert_offset; // 텍스트 영역 시작 위치 - for (pi, para) in text_box.paragraphs[..para_count].iter().enumerate() { + for (pi, para) in textbox_paragraphs[..para_count].iter().enumerate() { // 이 문단에 해당하는 composed 문단의 시작 y 위치 계산 let para_start_y = if pi < composed_paras.len() { if let Some(first_seg) = para.line_segs.first() { @@ -2309,6 +2619,7 @@ impl LayoutEngine { &empty_map, &nested_parent_path, None, // [Task #1138] TODO: layout_textbox_content 에 cell ctx propagate (별도 후속) + false, ); } Control::Picture(pic) => { diff --git a/src/renderer/layout/table_cell_content.rs b/src/renderer/layout/table_cell_content.rs index 89e27d625..9404b24ca 100644 --- a/src/renderer/layout/table_cell_content.rs +++ b/src/renderer/layout/table_cell_content.rs @@ -406,6 +406,7 @@ impl LayoutEngine { &empty_map, &[], shape_table_cell_ref, + false, ); } diff --git a/src/renderer/layout/tests.rs b/src/renderer/layout/tests.rs index 5d44cb0b0..610e283d8 100644 --- a/src/renderer/layout/tests.rs +++ b/src/renderer/layout/tests.rs @@ -3,8 +3,10 @@ use super::super::pagination::{ColumnContent, PageContent, PageItem}; use super::text_measurement::estimate_text_width; use super::utils::{expand_numbering_format, numbering_format_to_number_format}; use super::*; +use crate::model::footnote::Footnote; use crate::model::page::{ColumnDef, PageDef}; use crate::model::paragraph::{CharShapeRef, LineSeg, Paragraph}; +use crate::model::shape::RectangleShape; use crate::model::style::{Numbering, NumberingHead}; use crate::renderer::composer::compose_paragraph; use crate::renderer::style_resolver::ResolvedStyleSet; @@ -63,6 +65,85 @@ fn test_build_empty_page() { assert!(tree.root.children.len() >= 4); } +#[test] +fn footnote_area_uses_pagination_reserved_rect() { + let engine = LayoutEngine::with_default_dpi(); + let mut layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); + let reserved_height = layout.body_area.height * 0.75; + let expected_y = layout.body_area.y + layout.body_area.height - reserved_height; + layout.update_footnote_area(reserved_height); + + let note_para = Paragraph { + text: "각주 본문".to_string(), + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, + ..Default::default() + }], + ..Default::default() + }; + let paragraphs = vec![Paragraph { + text: "본문".to_string(), + controls: vec![Control::Footnote(Box::new(Footnote { + number: 1, + paragraphs: vec![note_para], + ..Default::default() + }))], + line_segs: vec![LineSeg { + line_height: 400, + baseline_distance: 320, + ..Default::default() + }], + ..Default::default() + }]; + let composed: Vec<_> = paragraphs.iter().map(|p| compose_paragraph(p)).collect(); + let page_content = PageContent { + page_index: 0, + page_number: 1, + section_index: 0, + layout, + column_contents: Vec::new(), + active_header: None, + active_footer: None, + page_number_pos: None, + page_hide: None, + footnotes: vec![FootnoteRef { + number: 1, + source: FootnoteSource::Body { + para_index: 0, + control_index: 0, + }, + }], + active_master_page: None, + extra_master_pages: Vec::new(), + }; + + let tree = engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &composed, + &ResolvedStyleSet::default(), + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ); + + let footnote_area = tree + .root + .children + .iter() + .find(|n| matches!(n.node_type, RenderNodeType::FootnoteArea)) + .expect("footnote area"); + assert!((footnote_area.bbox.height - reserved_height).abs() < 0.01); + assert!((footnote_area.bbox.y - expected_y).abs() < 0.01); +} + #[test] fn compact_endnote_tail_log_tolerance_allows_line_box_bleed_only() { let col_bottom = 1092.3; @@ -1534,3 +1615,513 @@ fn task1197_paper_nodes_sort_by_plane_z_order_and_stable_index() { "BehindText는 z-order/stable 순서로 먼저, flow, InFrontOfText 순으로 정렬" ); } + +#[test] +fn master_page_controls_sort_by_render_layer_z_order() { + fn rect_control(z_order: i32, horizontal_offset: u32) -> Control { + Control::Shape(Box::new(ShapeObject::Rectangle(RectangleShape { + common: CommonObjAttr { + width: 10_000, + height: 10_000, + horizontal_offset, + z_order, + text_wrap: TextWrap::InFrontOfText, + horz_rel_to: HorzRelTo::Paper, + vert_rel_to: VertRelTo::Paper, + ..Default::default() + }, + ..Default::default() + }))) + } + + let engine = LayoutEngine::with_default_dpi(); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); + let mut tree = PageRenderTree::new(0, layout.page_width, layout.page_height); + let master_page = MasterPage { + paragraphs: vec![Paragraph { + controls: vec![ + rect_control(20, 0), + rect_control(10, 20_000), + rect_control(20, 40_000), + ], + ..Default::default() + }], + text_width: 10_000, + text_height: 10_000, + ..Default::default() + }; + + engine.build_master_page_into( + &mut tree, + Some(&master_page), + &layout, + &[], + &ResolvedStyleSet::default(), + &[], + 0, + 1, + ); + + let master_node = tree + .root + .children + .iter() + .find(|node| matches!(node.node_type, RenderNodeType::MasterPage)) + .expect("master page node should be rendered"); + let z_order: Vec = master_node + .children + .iter() + .filter_map(|node| match node.node_type { + RenderNodeType::Rectangle(_) => node.layer.map(|layer| layer.z_order), + _ => None, + }) + .collect(); + + assert_eq!( + z_order, + vec![10, 20, 20], + "master-page children should replay Hancom object order, not raw control order" + ); +} + +fn first_master_child_layer(tree: &PageRenderTree, predicate: F) -> RenderLayerInfo +where + F: Fn(&RenderNodeType) -> bool + Copy, +{ + fn find(node: &RenderNode, predicate: F) -> Option + where + F: Fn(&RenderNodeType) -> bool + Copy, + { + if predicate(&node.node_type) { + return node.layer; + } + node.children + .iter() + .find_map(|child| find(child, predicate)) + } + + let master = tree + .root + .children + .iter() + .find(|node| matches!(node.node_type, RenderNodeType::MasterPage)) + .expect("master page node should be rendered"); + find(master, predicate).expect("matching master-page child should carry a layer") +} + +fn master_rect_control(width: u32, height: u32) -> Control { + Control::Shape(Box::new(ShapeObject::Rectangle(RectangleShape { + common: CommonObjAttr { + width, + height, + text_wrap: TextWrap::InFrontOfText, + horz_rel_to: HorzRelTo::Paper, + vert_rel_to: VertRelTo::Paper, + horz_align: HorzAlign::Left, + vert_align: VertAlign::Top, + ..Default::default() + }, + ..Default::default() + }))) +} + +#[test] +fn master_page_paper_sized_background_replays_behind_body_text() { + let page = a4_page_def(); + let tree = render_tree_with_master_page_control(master_rect_control(page.width, page.height)); + let layer = first_master_child_layer(&tree, |node_type| { + matches!(node_type, RenderNodeType::Rectangle(_)) + }); + + assert_eq!(layer.text_wrap, Some(TextWrap::BehindText)); +} + +#[test] +fn master_page_smaller_front_control_stays_in_front_of_body_text() { + let page = a4_page_def(); + let tree = + render_tree_with_master_page_control(master_rect_control(page.width / 2, page.height / 2)); + let layer = first_master_child_layer(&tree, |node_type| { + matches!(node_type, RenderNodeType::Rectangle(_)) + }); + + assert_eq!(layer.text_wrap, Some(TextWrap::InFrontOfText)); +} + +fn first_master_child_bbox(tree: &PageRenderTree, predicate: F) -> BoundingBox +where + F: Fn(&RenderNodeType) -> bool + Copy, +{ + fn find(node: &RenderNode, predicate: F) -> Option + where + F: Fn(&RenderNodeType) -> bool + Copy, + { + if predicate(&node.node_type) { + return Some(node.bbox); + } + node.children + .iter() + .find_map(|child| find(child, predicate)) + } + + let master = tree + .root + .children + .iter() + .find(|node| matches!(node.node_type, RenderNodeType::MasterPage)) + .expect("master page node should be rendered"); + find(master, predicate).expect("matching master-page child should be rendered") +} + +fn render_tree_with_master_page_control(control: Control) -> PageRenderTree { + let engine = LayoutEngine::with_default_dpi(); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); + let mut tree = PageRenderTree::new(0, layout.page_width, layout.page_height); + let master_page = MasterPage { + paragraphs: vec![Paragraph { + controls: vec![control], + ..Default::default() + }], + text_width: 10_000, + text_height: 10_000, + ..Default::default() + }; + + engine.build_master_page_into( + &mut tree, + Some(&master_page), + &layout, + &[], + &ResolvedStyleSet::default(), + &[], + 0, + 1, + ); + tree +} + +#[test] +fn master_page_paper_relative_shape_uses_page_origin() { + let tree = render_tree_with_master_page_control(Control::Shape(Box::new( + ShapeObject::Rectangle(RectangleShape { + common: CommonObjAttr { + width: 7_500, + height: 3_000, + horizontal_offset: 1_500, + vertical_offset: 2_250, + horz_rel_to: HorzRelTo::Paper, + vert_rel_to: VertRelTo::Paper, + text_wrap: TextWrap::InFrontOfText, + ..Default::default() + }, + ..Default::default() + }), + ))); + + let bbox = first_master_child_bbox(&tree, |node_type| { + matches!(node_type, RenderNodeType::Rectangle(_)) + }); + assert!((bbox.x - hwpunit_to_px(1_500, DEFAULT_DPI)).abs() < 0.01); + assert!((bbox.y - hwpunit_to_px(2_250, DEFAULT_DPI)).abs() < 0.01); +} + +#[test] +fn master_page_paper_relative_picture_uses_page_origin() { + let tree = render_tree_with_master_page_control(Control::Picture(Box::new( + crate::model::image::Picture { + common: CommonObjAttr { + width: 7_500, + height: 3_000, + horizontal_offset: 1_500, + vertical_offset: 2_250, + horz_rel_to: HorzRelTo::Paper, + vert_rel_to: VertRelTo::Paper, + text_wrap: TextWrap::InFrontOfText, + ..Default::default() + }, + ..Default::default() + }, + ))); + + let bbox = first_master_child_bbox(&tree, |node_type| { + matches!(node_type, RenderNodeType::Image(_)) + }); + assert!((bbox.x - hwpunit_to_px(1_500, DEFAULT_DPI)).abs() < 0.01); + assert!((bbox.y - hwpunit_to_px(2_250, DEFAULT_DPI)).abs() < 0.01); +} + +fn first_header_child_bbox(tree: &PageRenderTree, predicate: F) -> BoundingBox +where + F: Fn(&RenderNodeType) -> bool + Copy, +{ + fn find(node: &RenderNode, predicate: F) -> Option + where + F: Fn(&RenderNodeType) -> bool + Copy, + { + if predicate(&node.node_type) { + return Some(node.bbox); + } + node.children + .iter() + .find_map(|child| find(child, predicate)) + } + + let header = tree + .root + .children + .iter() + .find(|node| matches!(node.node_type, RenderNodeType::Header)) + .expect("header node should be rendered"); + find(header, predicate).expect("matching header child should be rendered") +} + +fn render_tree_with_header_control(control: Control) -> PageRenderTree { + use crate::model::header_footer::Header; + use crate::renderer::pagination::HeaderFooterRef; + + let engine = LayoutEngine::with_default_dpi(); + let layout = PageLayoutInfo::from_page_def_default(&a4_page_def(), &ColumnDef::default()); + let paragraphs = vec![Paragraph { + controls: vec![Control::Header(Box::new(Header { + paragraphs: vec![Paragraph { + controls: vec![control], + ..Default::default() + }], + ..Default::default() + }))], + ..Default::default() + }]; + let page_content = PageContent { + page_index: 0, + page_number: 1, + section_index: 0, + layout, + column_contents: Vec::new(), + active_header: Some(HeaderFooterRef { + para_index: 0, + control_index: 0, + source_section_index: 0, + }), + active_footer: None, + page_number_pos: None, + page_hide: None, + footnotes: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), + }; + engine.build_render_tree( + &page_content, + ¶graphs, + ¶graphs, + ¶graphs, + &[], + &ResolvedStyleSet::default(), + &FootnoteShape::default(), + &[], + None, + &[], + None, + 0, + &[], + ) +} + +#[test] +fn header_paper_relative_shape_uses_page_origin() { + let tree = render_tree_with_header_control(Control::Shape(Box::new(ShapeObject::Rectangle( + RectangleShape { + common: CommonObjAttr { + width: 7_500, + height: 3_000, + horizontal_offset: 1_500, + vertical_offset: 2_250, + horz_rel_to: HorzRelTo::Paper, + vert_rel_to: VertRelTo::Paper, + text_wrap: TextWrap::InFrontOfText, + ..Default::default() + }, + ..Default::default() + }, + )))); + + let bbox = first_header_child_bbox(&tree, |node_type| { + matches!(node_type, RenderNodeType::Rectangle(_)) + }); + assert!((bbox.x - hwpunit_to_px(1_500, DEFAULT_DPI)).abs() < 0.01); + assert!((bbox.y - hwpunit_to_px(2_250, DEFAULT_DPI)).abs() < 0.01); +} + +#[test] +fn header_paper_relative_picture_uses_page_origin() { + let tree = + render_tree_with_header_control(Control::Picture(Box::new(crate::model::image::Picture { + common: CommonObjAttr { + width: 7_500, + height: 3_000, + horizontal_offset: 1_500, + vertical_offset: 2_250, + horz_rel_to: HorzRelTo::Paper, + vert_rel_to: VertRelTo::Paper, + text_wrap: TextWrap::InFrontOfText, + ..Default::default() + }, + ..Default::default() + }))); + + let bbox = first_header_child_bbox(&tree, |node_type| { + matches!(node_type, RenderNodeType::Image(_)) + }); + assert!((bbox.x - hwpunit_to_px(1_500, DEFAULT_DPI)).abs() < 0.01); + assert!((bbox.y - hwpunit_to_px(2_250, DEFAULT_DPI)).abs() < 0.01); +} + +#[test] +fn group_child_matrix_coordinates_are_not_translated_twice() { + fn shape_attr( + tx: f64, + ty: f64, + width: u32, + height: u32, + ) -> crate::model::shape::ShapeComponentAttr { + crate::model::shape::ShapeComponentAttr { + original_width: width, + original_height: height, + current_width: width, + current_height: height, + render_tx: tx, + render_ty: ty, + render_sx: 1.0, + render_sy: 1.0, + ..Default::default() + } + } + + let engine = LayoutEngine::with_default_dpi(); + let expected_y = hwpunit_to_px(1000, engine.dpi); + let rect = ShapeObject::Rectangle(RectangleShape { + drawing: crate::model::shape::DrawingObjAttr { + shape_attr: shape_attr(0.0, 1000.0, 1000, 500), + ..Default::default() + }, + ..Default::default() + }); + let nested_group = ShapeObject::Group(crate::model::shape::GroupShape { + shape_attr: shape_attr(0.0, 1000.0, 1000, 500), + children: vec![rect], + ..Default::default() + }); + let outer_group = ShapeObject::Group(crate::model::shape::GroupShape { + shape_attr: shape_attr(0.0, 0.0, 1000, 1000), + children: vec![nested_group], + ..Default::default() + }); + let mut tree = PageRenderTree::new(0, 200.0, 200.0); + let mut parent = RenderNode::new( + tree.next_id(), + RenderNodeType::MasterPage, + BoundingBox::new(0.0, 0.0, 200.0, 200.0), + ); + + engine.layout_shape_object( + &mut tree, + &mut parent, + &outer_group, + 0.0, + 0.0, + hwpunit_to_px(1000, engine.dpi), + hwpunit_to_px(1000, engine.dpi), + 0, + 0, + 0, + &ResolvedStyleSet::default(), + &[], + &std::collections::HashMap::new(), + &[], + None, + false, + ); + + let outer = parent.children.first().expect("outer group rendered"); + let nested = outer.children.first().expect("nested group rendered"); + let child = nested.children.first().expect("child rendered"); + assert!( + (nested.bbox.y - expected_y).abs() < 0.01, + "nested group y should come from its composed rendering matrix" + ); + assert!( + (child.bbox.y - expected_y).abs() < 0.01, + "child matrix y should not add nested group bbox y again" + ); +} + +#[test] +fn top_level_group_anchor_offsets_local_child_matrices_once() { + fn shape_attr( + tx: f64, + ty: f64, + width: u32, + height: u32, + group_level: u16, + ) -> crate::model::shape::ShapeComponentAttr { + crate::model::shape::ShapeComponentAttr { + original_width: width, + original_height: height, + current_width: width, + current_height: height, + group_level, + render_tx: tx, + render_ty: ty, + render_sx: 1.0, + render_sy: 1.0, + ..Default::default() + } + } + + let engine = LayoutEngine::with_default_dpi(); + let anchor_y = hwpunit_to_px(2000, engine.dpi); + let local_child_y = hwpunit_to_px(1000, engine.dpi); + let rect = ShapeObject::Rectangle(RectangleShape { + drawing: crate::model::shape::DrawingObjAttr { + shape_attr: shape_attr(0.0, 1000.0, 1000, 500, 1), + ..Default::default() + }, + ..Default::default() + }); + let outer_group = ShapeObject::Group(crate::model::shape::GroupShape { + shape_attr: shape_attr(0.0, 0.0, 1000, 1000, 0), + children: vec![rect], + ..Default::default() + }); + let mut tree = PageRenderTree::new(0, 200.0, 200.0); + let mut parent = RenderNode::new( + tree.next_id(), + RenderNodeType::MasterPage, + BoundingBox::new(0.0, 0.0, 200.0, 200.0), + ); + + engine.layout_shape_object( + &mut tree, + &mut parent, + &outer_group, + 0.0, + anchor_y, + hwpunit_to_px(1000, engine.dpi), + hwpunit_to_px(1000, engine.dpi), + 0, + 0, + 0, + &ResolvedStyleSet::default(), + &[], + &std::collections::HashMap::new(), + &[], + None, + false, + ); + + let outer = parent.children.first().expect("outer group rendered"); + let child = outer.children.first().expect("child rendered"); + assert!( + (child.bbox.y - (anchor_y + local_child_y)).abs() < 0.01, + "top-level group anchor should offset local child matrices exactly once" + ); +} diff --git a/src/renderer/layout/text_measurement.rs b/src/renderer/layout/text_measurement.rs index 1eb1d384c..4b769d871 100644 --- a/src/renderer/layout/text_measurement.rs +++ b/src/renderer/layout/text_measurement.rs @@ -916,7 +916,10 @@ mod wasm_internals { /// 1000pt 측정용 CSS font 문자열 생성 pub(super) fn build_1000pt_font_string(style: &TextStyle) -> String { - let font_weight = if style.bold { "bold " } else { "" }; + let font_weight = style + .css_font_weight() + .map(|weight| format!("{} ", weight)) + .unwrap_or_default(); let font_style = if style.italic { "italic " } else { "" }; let font_family = if style.font_family.is_empty() { "sans-serif".to_string() @@ -1581,10 +1584,54 @@ fn is_monospace_metric(metric: &font_metrics_data::FontMetric) -> bool { count >= 16 } +/// 요청 폰트의 내장 메트릭 DB 등록 여부. +/// +/// `compute_char_positions` 의 advance 가 실제 글리프 폭(메트릭 DB)에서 +/// 나온 값인지, 아니면 DB 미등록 폰트의 휴리스틱 폴백(`font_size * 0.5` +/// 등)인지 구분하는 데 쓴다. WASM 캔버스 렌더러는 메트릭이 없는(=브라우저 +/// 대체 폰트로 치환되는) 폰트에 대해 글리프별 가로 스케일링(per-glyph +/// x-scale)을 적용하면 안 된다 — 치환 폰트의 실제 advance 와 어긋나 +/// l/i/t 같은 좁은 글리프가 과도하게 늘어나기 때문이다 (한컴 바겐세일 M +/// → Pretendard 치환 시 Vocabulary 열 왜곡). +pub(crate) fn font_family_has_metrics(font_family: &str, bold: bool, italic: bool) -> bool { + let primary_name = font_family.split(',').next().unwrap_or(font_family).trim(); + font_metrics_data::find_metric(primary_name, bold, italic).is_some() +} + /// 내장 폰트 메트릭으로 문자 폭 측정 (em 단위 → px 변환) /// /// 내장 메트릭이 있으면 JS 브릿지 호출 없이 즉시 반환. /// 없으면 None을 반환하여 폴백 경로를 사용하게 한다. +fn quantize_hwp_px(px: f64) -> f64 { + let hwp = (px * 75.0) as i32; + hwp as f64 / 75.0 +} + +fn kopub_char_width(primary_name: &str, c: char, font_size: f64) -> Option { + let lower = primary_name.to_lowercase(); + let is_dotum = primary_name.contains("KoPub돋움체") || lower.contains("kopub dotum"); + let is_batang = primary_name.contains("KoPub바탕체") || lower.contains("kopub batang"); + if !is_dotum && !is_batang { + return None; + } + + if c == ' ' { + return Some(quantize_hwp_px(font_size * 0.5)); + } + if is_narrow_punctuation(c) { + return Some(quantize_hwp_px(font_size * 0.3)); + } + if c.is_ascii() { + return Some(quantize_hwp_px(font_size * 0.5)); + } + if is_cjk_char(c) || is_fullwidth_symbol(c) { + let factor = if is_dotum { 0.84 } else { 0.94 }; + return Some(quantize_hwp_px(font_size * factor)); + } + + None +} + fn measure_char_width_embedded( font_family: &str, bold: bool, @@ -1594,6 +1641,9 @@ fn measure_char_width_embedded( ) -> Option { // CSS font-family 체인에서 첫 번째 폰트명으로 메트릭 조회 let primary_name = font_family.split(',').next().unwrap_or(font_family).trim(); + if let Some(w) = kopub_char_width(primary_name, c, font_size) { + return Some(w); + } let mm = font_metrics_data::find_metric(primary_name, bold, italic)?; // HWP 반각 처리: space 및 한컴이 반각으로 처리하는 구두점/기호 let w = if c == ' ' { @@ -1645,8 +1695,7 @@ fn measure_char_width_embedded( // 한컴과 동일한 HWPUNIT 정수 변환: w * base_size / em (내림) // round가 아닌 truncate (as i32)로 처리하여 한컴 정수 나눗셈과 일치 - let hwp = (actual_px * 75.0) as i32; - Some(hwp as f64 / 75.0) + Some(quantize_hwp_px(actual_px)) } // ── 호환 래퍼 (기존 호출부 변경 없음) ────────────────────────────── @@ -1821,6 +1870,7 @@ fn is_fullwidth_symbol(c: char) -> bool { '\u{00A3}' | // £ POUND SIGN '\u{00A5}' // ¥ YEN SIGN ) + || ('\u{2190}'..='\u{21FF}').contains(&c) // Arrows (→, ⇨, ⇒ 등) || ('\u{2460}'..='\u{24FF}').contains(&c) // Enclosed Alphanumerics (①②③ 등) || ('\u{25A0}'..='\u{25FF}').contains(&c) // Geometric Shapes (□■▲◆○ 등, 섹션 머리 기호) || ('\u{2600}'..='\u{26FF}').contains(&c) // Miscellaneous Symbols (☆★ 등) @@ -2079,6 +2129,22 @@ mod tests { assert!((w - 35.0).abs() < 0.01, "expected 35.0, got {}", w); } + #[test] + fn test_unicode_arrow_uses_symbol_advance() { + let style = TextStyle { + font_family: "KoPub돋움체 Light".to_string(), + font_size: 10.0, + ..Default::default() + }; + + let arrow = estimate_text_width("⇒", &style); + let ascii = estimate_text_width("A", &style); + assert!( + arrow > ascii, + "arrow should use symbol advance, arrow={arrow}, ascii={ascii}" + ); + } + #[test] fn test_mock_measurer_tab() { let m = MockTextMeasurer { char_width: 10.0 }; @@ -2135,6 +2201,19 @@ mod tests { ); } + #[test] + fn test_kopub_dotum_uses_narrow_publication_metrics() { + let m = EmbeddedTextMeasurer; + let style = TextStyle { + font_family: "KoPub돋움체 Light".to_string(), + font_size: 14.0, + ..Default::default() + }; + + let w = m.estimate_text_width("가나", &style); + assert_eq!(w, 24.0); + } + #[test] fn test_embedded_measurer_known_font() { let m = EmbeddedTextMeasurer; diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 273f6a396..62b394ee3 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -204,13 +204,28 @@ impl TextStyle { /// 시각 bold 소실을 보완하기 위해 SVG 출력 시 font-weight="bold" 강제에 /// 사용된다. pub fn is_visually_bold(&self) -> bool { - self.bold || crate::renderer::style_resolver::is_heavy_display_face(&self.font_family) + self.bold + || crate::renderer::style_resolver::is_heavy_display_face(&self.font_family) + || crate::renderer::style_resolver::is_bold_weight_face(&self.font_family) } /// 중고딕 계열(font-weight 500) 여부. SVG/HTML 출력 시 `font-weight: 500` 힌트 삽입에 사용. pub fn is_medium_weight(&self) -> bool { !self.bold && crate::renderer::style_resolver::is_medium_weight_face(&self.font_family) } + + /// CSS/SVG font-weight hint for fallback rendering. + pub fn css_font_weight(&self) -> Option<&'static str> { + if self.is_visually_bold() { + Some("bold") + } else if crate::renderer::style_resolver::is_light_weight_face(&self.font_family) { + Some("300") + } else if self.is_medium_weight() { + Some("500") + } else { + None + } + } } impl Default for TextStyle { @@ -705,6 +720,16 @@ pub fn generic_fallback(font_family: &str) -> &'static str { } // 고정폭 키워드 let lower = font_family.to_ascii_lowercase(); + if (font_family.contains("KoPub돋움체") || lower.contains("kopub dotum")) + && (font_family.contains("Light") || lower.contains("light")) + { + return "'Noto Sans KR ExtraLight','Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans KR','Pretendard','HCR Batang Ext-B','함초롬바탕 확장B','HCR Batang Ext','함초롬바탕 확장','HCR Batang','함초롬바탕','Source Han Serif K Old Hangul',sans-serif"; + } + // KoPub Batang uses "바탕체" in the family name, but it is a proportional + // serif publication face, not the Windows fixed-width BatangChe face. + if font_family.contains("KoPub바탕체") || lower.contains("kopub batang") { + return "'Batang','바탕','Nanum Myeongjo','AppleMyungjo','Noto Serif KR','Noto Serif CJK KR','HCR Batang Ext-B','함초롬바탕 확장B','HCR Batang Ext','함초롬바탕 확장','HCR Batang','함초롬바탕','Source Han Serif K Old Hangul',serif"; + } if font_family.contains("굴림체") || font_family.contains("바탕체") || lower.contains("gulimche") @@ -1141,12 +1166,21 @@ mod tests { assert_eq!(generic_fallback("HY견명조"), serif); assert_eq!(generic_fallback("Times New Roman"), serif); assert_eq!(generic_fallback("Palatino Linotype"), serif); + // KoPub바탕체는 이름에 "바탕체"가 들어가지만 고정폭 BatangChe가 아니라 + // 비례폭 본문/제목용 세리프 계열이다. + assert_eq!(generic_fallback("KoPub바탕체 Light"), serif); + assert_eq!(generic_fallback("KoPub바탕체 Medium"), serif); + assert_eq!(generic_fallback("KoPub Batang Medium"), serif); // 산세리프 계열 assert_eq!(generic_fallback("함초롬돋움"), sans); assert_eq!(generic_fallback("돋움"), sans); assert_eq!(generic_fallback("굴림"), sans); assert_eq!(generic_fallback("Arial"), sans); assert_eq!(generic_fallback("맑은 고딕"), sans); + assert!(generic_fallback("KoPub돋움체 Light") + .starts_with("'Noto Sans KR ExtraLight','Malgun Gothic'")); + assert!(generic_fallback("KoPub Dotum Light") + .starts_with("'Noto Sans KR ExtraLight','Malgun Gothic'")); // 고정폭 계열 assert_eq!(generic_fallback("굴림체"), mono); assert_eq!(generic_fallback("바탕체"), mono); @@ -1179,6 +1213,22 @@ mod tests { assert!(!is_medium_weight_face("")); } + #[test] + fn test_explicit_face_weight_hints() { + let light = TextStyle { + font_family: "KoPub돋움체 Light".to_string(), + ..Default::default() + }; + assert_eq!(light.css_font_weight(), Some("300")); + + let bold = TextStyle { + font_family: "KoPub바탕체 Bold".to_string(), + ..Default::default() + }; + assert_eq!(bold.css_font_weight(), Some("bold")); + assert!(bold.is_visually_bold()); + } + #[test] fn test_format_number_hangul() { assert_eq!(format_number(1, NumberFormat::HangulGaNaDa), "가"); diff --git a/src/renderer/page_layout.rs b/src/renderer/page_layout.rs index 7d86955c1..68ee6b8b7 100644 --- a/src/renderer/page_layout.rs +++ b/src/renderer/page_layout.rs @@ -158,12 +158,12 @@ impl PageLayoutInfo { /// 각주 영역을 동적으로 계산하여 레이아웃을 갱신한다. /// - /// 각주 높이만큼 본문 영역 하단을 축소하고 각주 영역을 설정한다. + /// 각주 높이만큼 본문 영역 하단에 각주 영역을 설정한다. pub fn update_footnote_area(&mut self, footnote_height: f64) { if footnote_height <= 0.0 { return; } - let h = footnote_height.min(self.body_area.height * 0.5); // 본문의 절반까지만 + let h = footnote_height.min(self.body_area.height); self.footnote_area = LayoutRect { x: self.body_area.x, y: self.body_area.y + self.body_area.height - h, diff --git a/src/renderer/pagination.rs b/src/renderer/pagination.rs index 0874b4071..0da690dd9 100644 --- a/src/renderer/pagination.rs +++ b/src/renderer/pagination.rs @@ -12,11 +12,40 @@ use super::height_measurer::{HeightMeasurer, MeasuredSection}; use super::page_layout::PageLayoutInfo; use super::style_resolver::ResolvedStyleSet; use crate::model::control::Control; +use crate::model::footnote::{Footnote, FootnoteShape}; use crate::model::header_footer::HeaderFooterApply; use crate::model::page::{ColumnDef, PageDef}; use crate::model::paragraph::{ColumnBreakType, Paragraph}; use crate::model::shape::CaptionDirection; +pub fn estimate_footnote_note_height(footnote: &Footnote, dpi: f64) -> f64 { + let mut height = 0.0; + for para in &footnote.paragraphs { + if para.line_segs.is_empty() { + height += super::hwpunit_to_px(400, dpi); + } else { + for seg in ¶.line_segs { + height += super::hwpunit_to_px(seg.line_height, dpi); + } + } + } + if height <= 0.0 { + super::hwpunit_to_px(400, dpi) + } else { + height + } +} + +pub fn footnote_separator_overhead_px(shape: &FootnoteShape, dpi: f64) -> f64 { + super::hwpunit_to_px(shape.separator_above_margin_hu() as i32, dpi) + + super::layout::border_width_to_px(shape.separator_line_width).max(0.5) + + super::hwpunit_to_px(shape.separator_below_margin_hu() as i32, dpi) +} + +pub fn footnote_between_notes_margin_px(shape: &FootnoteShape, dpi: f64) -> f64 { + super::hwpunit_to_px(shape.between_notes_margin_hu() as i32, dpi) +} + /// 미주 참조 #[derive(Debug, Clone)] pub struct EndnoteRef { @@ -667,6 +696,8 @@ pub struct PaginationOpts { /// 페이지 절반 이상 + 현재 paragraph 의 first_line vpos 가 페이지 1/4 이내) /// 시 강제 page break — 한컴 변환 시 인코딩한 page break 시그널 인식. pub is_hwp3_variant: bool, + /// 현재 구역의 각주 모양. 각주 예약 영역을 렌더 영역과 같은 metric으로 계산한다. + pub footnote_shape: Option, } /// 페이지 분할 엔진 diff --git a/src/renderer/pagination/engine.rs b/src/renderer/pagination/engine.rs index 607bf35e5..31c732f77 100644 --- a/src/renderer/pagination/engine.rs +++ b/src/renderer/pagination/engine.rs @@ -98,6 +98,28 @@ fn positive_vpos_end_before_negative_wrap(para: &Paragraph) -> Option { .max() } +fn single_line_text_box_bottom_px(para: &Paragraph, page_vpos_base: i32, dpi: f64) -> Option { + let mut real_lines = para + .line_segs + .iter() + .filter(|ls| !is_synthetic_line_seg(ls)); + let line = real_lines.next()?; + if real_lines.next().is_some() || line.vertical_pos <= page_vpos_base { + return None; + } + + let text_height = if line.text_height > 0 { + line.text_height + } else { + line.line_height.max(0) + }; + let bottom = line + .vertical_pos + .saturating_add(text_height) + .saturating_sub(page_vpos_base); + (bottom >= 0).then(|| crate::renderer::hwpunit_to_px(bottom, dpi)) +} + impl Paginator { pub fn paginate_with_measured( &self, @@ -155,7 +177,15 @@ impl Paginator { Self::collect_header_footer_controls(paragraphs, section_index); let col_count = column_def.column_count.max(1); - let footnote_separator_overhead = crate::renderer::hwpunit_to_px(400, self.dpi); + let default_footnote_shape = crate::model::footnote::FootnoteShape::default(); + let footnote_shape = opts + .footnote_shape + .as_ref() + .unwrap_or(&default_footnote_shape); + let footnote_separator_overhead = + super::footnote_separator_overhead_px(footnote_shape, self.dpi); + let footnote_between_notes_margin = + super::footnote_between_notes_margin_px(footnote_shape, self.dpi); let footnote_safety_margin = crate::renderer::hwpunit_to_px(3000, self.dpi); let mut st = PaginationState::new( @@ -163,6 +193,7 @@ impl Paginator { col_count, section_index, footnote_separator_overhead, + footnote_between_notes_margin, footnote_safety_margin, ); @@ -1169,7 +1200,14 @@ impl Paginator { trailing_ls }; // 부동소수점 누적 오차 허용 (0.5px ≈ 0.13mm) - st.current_height + (para_height - effective_trailing) <= available_now + 0.5 + let advance_fits = + st.current_height + (para_height - effective_trailing) <= available_now + 0.5; + let page_vpos_base = st.page_vpos_base.unwrap_or(0); + let text_box_fits = !para.line_segs.is_empty() + && !st.current_items.is_empty() + && single_line_text_box_bottom_px(para, page_vpos_base, self.dpi) + .is_some_and(|bottom| bottom <= available_now + 0.5); + advance_fits || text_box_fits } { // 문단 전체가 현재 페이지에 들어감 st.current_items.push(PageItem::FullParagraph { @@ -1659,8 +1697,9 @@ impl Paginator { tb_control_index: tc_idx, }, }); - let fn_height = - measurer.estimate_single_footnote_height(&fn_ctrl); + let fn_height = super::estimate_footnote_note_height( + &fn_ctrl, self.dpi, + ); st.add_footnote_height(fn_height); } } @@ -1707,7 +1746,7 @@ impl Paginator { control_index: ctrl_idx, }, }); - let fn_height = measurer.estimate_single_footnote_height(fn_ctrl); + let fn_height = super::estimate_footnote_note_height(fn_ctrl, self.dpi); st.add_footnote_height(fn_height); } } @@ -1772,24 +1811,22 @@ impl Paginator { // 표 내 각주 높이 사전 계산 let mut table_footnote_height = 0.0; - let mut table_has_footnotes = false; + let mut table_footnote_count = 0usize; for cell in &table.cells { for cp in &cell.paragraphs { for cc in &cp.controls { if let Control::Footnote(fn_ctrl) = cc { - let fn_height = measurer.estimate_single_footnote_height(fn_ctrl); - if !table_has_footnotes && st.is_first_footnote_on_page { - table_footnote_height += st.footnote_separator_overhead; - } + let fn_height = super::estimate_footnote_note_height(fn_ctrl, self.dpi); table_footnote_height += fn_height; - table_has_footnotes = true; + table_footnote_count += 1; } } } } // 현재 사용 가능한 높이 - let total_footnote = st.current_footnote_height + table_footnote_height; + let total_footnote = + st.projected_footnote_height(table_footnote_height, table_footnote_count); let table_margin = if total_footnote > 0.0 { st.footnote_safety_margin } else { @@ -2052,7 +2089,7 @@ impl Paginator { cell_control_index: cc_idx, }, }); - let fn_height = measurer.estimate_single_footnote_height(fn_ctrl); + let fn_height = super::estimate_footnote_note_height(fn_ctrl, self.dpi); st.add_footnote_height(fn_height); } } diff --git a/src/renderer/pagination/state.rs b/src/renderer/pagination/state.rs index f9eaf3f22..2cbead815 100644 --- a/src/renderer/pagination/state.rs +++ b/src/renderer/pagination/state.rs @@ -24,6 +24,7 @@ pub(super) struct PaginationState { pub on_first_multicolumn_page: bool, pub section_index: usize, pub footnote_separator_overhead: f64, + pub footnote_between_notes_margin: f64, pub footnote_safety_margin: f64, /// 현재 단에 축적된 어울림 리턴 문단 목록 pub current_column_wrap_around_paras: Vec, @@ -51,6 +52,7 @@ impl PaginationState { col_count: u16, section_index: usize, footnote_separator_overhead: f64, + footnote_between_notes_margin: f64, footnote_safety_margin: f64, ) -> Self { Self { @@ -67,6 +69,7 @@ impl PaginationState { on_first_multicolumn_page: false, section_index, footnote_separator_overhead, + footnote_between_notes_margin, footnote_safety_margin, current_column_wrap_around_paras: Vec::new(), current_column_wrap_anchors: std::collections::HashMap::new(), @@ -207,8 +210,41 @@ impl PaginationState { if self.is_first_footnote_on_page { self.current_footnote_height += self.footnote_separator_overhead; self.is_first_footnote_on_page = false; + } else { + self.current_footnote_height += self.footnote_between_notes_margin; } self.current_footnote_height += height; + self.sync_current_page_footnote_area(); + } + + pub fn projected_footnote_height(&self, note_content_height: f64, note_count: usize) -> f64 { + if note_count == 0 { + return self.current_footnote_height; + } + let separator = if self.is_first_footnote_on_page { + self.footnote_separator_overhead + } else { + 0.0 + }; + let between_count = if self.is_first_footnote_on_page { + note_count.saturating_sub(1) + } else { + note_count + }; + self.current_footnote_height + + separator + + self.footnote_between_notes_margin * between_count as f64 + + note_content_height + } + + fn sync_current_page_footnote_area(&mut self) { + if self.current_footnote_height <= 0.0 { + return; + } + if let Some(page) = self.pages.last_mut() { + page.layout + .update_footnote_area(self.current_footnote_height); + } } /// 새 페이지 push + 상태 리셋 diff --git a/src/renderer/pagination/tests.rs b/src/renderer/pagination/tests.rs index d3e469da8..741524ea5 100644 --- a/src/renderer/pagination/tests.rs +++ b/src/renderer/pagination/tests.rs @@ -27,6 +27,60 @@ fn make_paragraph_with_height(line_height: i32) -> Paragraph { } } +#[test] +fn page_bottom_text_box_fit_keeps_line_even_when_advance_overflows() { + let paginator = Paginator::with_default_dpi(); + let styles = ResolvedStyleSet::default(); + let page_def = a4_page_def(); + let body_height_hu = page_def + .height + .saturating_sub(page_def.margin_top) + .saturating_sub(page_def.margin_bottom) + .saturating_sub(page_def.margin_header) + .saturating_sub(page_def.margin_footer) as i32; + + let lead_advance = body_height_hu - 580; + let lead = Paragraph { + line_segs: vec![LineSeg { + vertical_pos: 0, + line_height: lead_advance, + text_height: body_height_hu - 2500, + line_spacing: 0, + ..Default::default() + }], + ..Default::default() + }; + let bottom_line = Paragraph { + line_segs: vec![LineSeg { + vertical_pos: body_height_hu - 1200, + line_height: 1200, + text_height: 1200, + line_spacing: 840, + ..Default::default() + }], + ..Default::default() + }; + let composed: Vec = Vec::new(); + let (result, _measured) = paginator.paginate( + &[lead, bottom_line], + &composed, + &styles, + &page_def, + &ColumnDef::default(), + 0, + ); + + assert_eq!(result.pages.len(), 1); + let items = &result.pages[0].column_contents[0].items; + assert!(matches!( + items.as_slice(), + [ + PageItem::FullParagraph { para_index: 0 }, + PageItem::FullParagraph { para_index: 1 } + ] + )); +} + #[test] fn test_empty_paragraphs() { let paginator = Paginator::with_default_dpi(); diff --git a/src/renderer/skia/image_conv.rs b/src/renderer/skia/image_conv.rs index 0212c2aee..7f57c8c39 100644 --- a/src/renderer/skia/image_conv.rs +++ b/src/renderer/skia/image_conv.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, OnceLock}; use crate::model::image::ImageEffect; use crate::model::style::ImageFillMode; +use crate::renderer::image_resolver::{detect_image_mime_type, grayscale_jpeg_bytes_to_png_bytes}; const MAX_SVG_FRAGMENT_BYTES: usize = 4 * 1024 * 1024; const MAX_SVG_RASTER_PIXELS: u64 = 67_108_864; @@ -146,7 +147,14 @@ pub fn draw_image_bytes( if !is_valid_destination_rect(x, y, width, height) { return; } - let Some(image) = Image::from_encoded(Data::new_copy(bytes)) else { + let normalized_bytes = if detect_image_mime_type(bytes) == "image/jpeg" { + grayscale_jpeg_bytes_to_png_bytes(bytes) + } else { + None + }; + let encoded_bytes = normalized_bytes.as_deref().unwrap_or(bytes); + + let Some(image) = Image::from_encoded(Data::new_copy(encoded_bytes)) else { draw_missing_image_placeholder(x, y, width, height); return; }; @@ -227,6 +235,7 @@ pub fn draw_image_bytes( if matches!( mode, ImageFillMode::TileAll + | ImageFillMode::Total | ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom | ImageFillMode::TileVertLeft @@ -276,7 +285,9 @@ pub fn draw_image_bytes( true }; - if matches!(mode, ImageFillMode::TileAll) && draw_tiled_shader(dst, x, y) { + if matches!(mode, ImageFillMode::TileAll | ImageFillMode::Total) + && draw_tiled_shader(dst, x, y) + { canvas.restore(); return; } diff --git a/src/renderer/style_resolver.rs b/src/renderer/style_resolver.rs index e396d08d6..5bba910b9 100644 --- a/src/renderer/style_resolver.rs +++ b/src/renderer/style_resolver.rs @@ -181,9 +181,12 @@ pub struct ResolvedParaStyle { pub tab_stops: Vec, /// 문단 오른쪽 끝 자동 탭 여부 pub auto_tab_right: bool, + /// HWPX paraPr condense / HWP ParaShape attr1 bits 9..15. + /// Spec name: minimum spacing value, 0..75%. + pub condense_min_space: u8, /// 줄 나눔 기준 영어 단위 (0=단어, 1=하이픈, 2=글자) — attr1 bit 5-6 pub english_break_unit: u8, - /// 줄 나눔 기준 한글 단위 (0=어절, 1=글자) — attr1 bit 7 + /// 줄 나눔 기준 한글 단위 (0=글자, 1=어절/KEEP_WORD) — attr1 bit 7 pub korean_break_unit: u8, /// 외톨이줄 보호 — attr1 bit 16 pub widow_orphan: bool, @@ -214,6 +217,7 @@ impl Default for ResolvedParaStyle { default_tab_width: 0.0, tab_stops: Vec::new(), auto_tab_right: false, + condense_min_space: 0, english_break_unit: 0, korean_break_unit: 0, widow_orphan: false, @@ -728,18 +732,40 @@ pub(crate) fn is_heavy_display_face(font_family: &str) -> bool { ) } -/// 중고딕/태고딕 계열 (CSS font-weight 500) 폰트 판별. -/// -/// HWP 에서 중고딕 계열은 Regular(400)과 Bold(700) 사이의 Medium(500) weight. -/// Fallback 폰트 매칭 시 weight 500 힌트를 주어 선명도를 유지한다. -pub(crate) fn is_medium_weight_face(font_family: &str) -> bool { - let primary = font_family +fn primary_font_face(font_family: &str) -> &str { + font_family .split(',') .next() .unwrap_or(font_family) .trim() .trim_matches('\'') - .trim_matches('"'); + .trim_matches('"') +} + +/// Face name explicitly carries a bold weight. +pub(crate) fn is_bold_weight_face(font_family: &str) -> bool { + let primary = primary_font_face(font_family); + let lower = primary.to_lowercase(); + lower.contains("bold") || lower.contains("볼드") +} + +/// Face name explicitly carries a light weight. +pub(crate) fn is_light_weight_face(font_family: &str) -> bool { + let primary = primary_font_face(font_family); + let lower = primary.to_lowercase(); + !is_bold_weight_face(font_family) + && (lower.contains("light") + || lower.contains("extralight") + || lower.contains("thin") + || lower.contains("ultralight")) +} + +/// 중고딕/태고딕 계열 (CSS font-weight 500) 폰트 판별. +/// +/// HWP 에서 중고딕 계열은 Regular(400)과 Bold(700) 사이의 Medium(500) weight. +/// Fallback 폰트 매칭 시 weight 500 힌트를 주어 선명도를 유지한다. +pub(crate) fn is_medium_weight_face(font_family: &str) -> bool { + let primary = primary_font_face(font_family); let lower = primary.to_lowercase(); lower.contains("중고딕") || lower.contains("태고딕") @@ -830,6 +856,7 @@ fn resolve_single_para_style( default_tab_width, tab_stops, auto_tab_right, + condense_min_space: ((ps.attr1 >> 9) & 0x7f).min(75) as u8, english_break_unit: ((ps.attr1 >> 5) & 0x03) as u8, korean_break_unit: ((ps.attr1 >> 7) & 0x01) as u8, widow_orphan: (ps.attr1 >> 16) & 1 != 0 || (ps.attr2 >> 5) & 1 != 0, diff --git a/src/renderer/svg.rs b/src/renderer/svg.rs index 1d61a730e..90d17e37c 100644 --- a/src/renderer/svg.rs +++ b/src/renderer/svg.rs @@ -8,9 +8,9 @@ use super::composer::{ }; use super::form_caption::display_form_caption; pub(crate) use super::image_resolver::{ - bmp_bytes_to_png_bytes, detect_image_mime_type, pcx_bytes_to_png_bytes, - real_picture_watermark_bytes_to_hancom_tone_png_bytes, - real_picture_watermark_fill_bytes_to_hancom_tone_png_bytes, + bmp_bytes_to_png_bytes, detect_image_mime_type, grayscale_jpeg_bytes_to_png_bytes, + pcx_bytes_to_png_bytes, real_picture_watermark_bytes_to_hancom_tone_png_bytes, + real_picture_watermark_fill_bytes_to_hancom_tone_png_bytes, tiff_bytes_to_png_bytes, watermark_jpeg_bytes_to_hancom_baked_png_bytes, }; use super::pua_oldhangul::map_pua_old_hangul; @@ -260,11 +260,18 @@ impl SvgRenderer { RenderNodeType::PageBackground(bg) => { // 배경색 먼저 (이미지가 투명 부분을 가질 수 있으므로) if let Some(color) = bg.background_color { - let color_str = color_to_svg(color); - self.output.push_str(&format!( - "\n", - node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, color_str, - )); + let is_default_white_page_fill = (color & 0x00ff_ffff) == 0x00ff_ffff + && node.bbox.x.abs() < 0.001 + && node.bbox.y.abs() < 0.001 + && (node.bbox.width - self.width).abs() < 0.001 + && (node.bbox.height - self.height).abs() < 0.001; + if !is_default_white_page_fill { + let color_str = color_to_svg(color); + self.output.push_str(&format!( + "\n", + node.bbox.x, node.bbox.y, node.bbox.width, node.bbox.height, color_str, + )); + } } // 그라데이션 (배경색 위에 덮음) if let Some(grad) = &bg.gradient { @@ -322,10 +329,8 @@ impl SvgRenderer { }; let mut attrs = format!("font-family=\"{}\" font-size=\"{}\" fill=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\"", escape_xml(&font_family), font_size, color); - if run.style.is_visually_bold() { - attrs.push_str(" font-weight=\"bold\""); - } else if run.style.is_medium_weight() { - attrs.push_str(" font-weight=\"500\""); + if let Some(weight) = run.style.css_font_weight() { + attrs.push_str(&format!(" font-weight=\"{}\"", weight)); } if run.style.italic { attrs.push_str(" font-style=\"italic\""); @@ -464,7 +469,13 @@ impl SvgRenderer { } RenderNodeType::Path(path) => { self.open_shape_transform(&path.transform, &node.bbox); - self.draw_path_with_gradient(&path.commands, &path.style, path.gradient.as_deref()); + let connector_line = path.line_style.as_ref().zip(path.connector_endpoints); + self.draw_path_with_gradient_and_line_style( + &path.commands, + &path.style, + path.gradient.as_deref(), + connector_line, + ); } RenderNodeType::Equation(eq) => { // 수식 SVG 조각을 bbox 위치에 배치 @@ -1218,6 +1229,16 @@ impl SvgRenderer { commands: &[PathCommand], style: &ShapeStyle, gradient: Option<&GradientFillInfo>, + ) { + self.draw_path_with_gradient_and_line_style(commands, style, gradient, None); + } + + fn draw_path_with_gradient_and_line_style( + &mut self, + commands: &[PathCommand], + style: &ShapeStyle, + gradient: Option<&GradientFillInfo>, + connector_line: Option<(&LineStyle, (f64, f64, f64, f64))>, ) { let mut d = String::new(); for cmd in commands { @@ -1262,6 +1283,35 @@ impl SvgRenderer { } } + if let Some((line_style, (x1, y1, x2, y2))) = connector_line { + let line_len = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) + .sqrt() + .max(1.0); + let color = color_to_svg(line_style.color); + if line_style.start_arrow != super::ArrowStyle::None { + let marker_id = self.ensure_arrow_marker( + &color, + line_style.width, + line_len, + &line_style.start_arrow, + line_style.start_arrow_size, + true, + ); + attrs.push_str(&format!(" marker-start=\"url(#{})\"", marker_id)); + } + if line_style.end_arrow != super::ArrowStyle::None { + let marker_id = self.ensure_arrow_marker( + &color, + line_style.width, + line_len, + &line_style.end_arrow, + line_style.end_arrow_size, + false, + ); + attrs.push_str(&format!(" marker-end=\"url(#{})\"", marker_id)); + } + } + self.output.push_str(&format!("\n", attrs)); } @@ -1356,7 +1406,7 @@ impl SvgRenderer { // 놓쳐 opacity 가 빠지는 회귀를 냈다. let is_watermark_image = img.is_watermark(); let detected_mime = detect_image_mime_type(&img.data); - // BMP/PCX → PNG 재인코딩 (브라우저 호환성과 PCX white transparency 정합) + // BMP/PCX/TIFF → PNG 재인코딩 (브라우저 호환성과 PCX white transparency 정합) let (render_bytes, render_mime): (std::borrow::Cow<[u8]>, &str) = if preserve_color_watermark { match real_picture_watermark_bytes_to_hancom_tone_png_bytes(&img.data) { @@ -1382,6 +1432,22 @@ impl SvgRenderer { detected_mime, ), } + } else if detected_mime == "image/tiff" { + match tiff_bytes_to_png_bytes(&img.data) { + Some(png) => (std::borrow::Cow::Owned(png), "image/png"), + None => ( + std::borrow::Cow::Borrowed(img.data.as_slice()), + detected_mime, + ), + } + } else if detected_mime == "image/jpeg" { + match grayscale_jpeg_bytes_to_png_bytes(&img.data) { + Some(png) => (std::borrow::Cow::Owned(png), "image/png"), + None => ( + std::borrow::Cow::Borrowed(img.data.as_slice()), + detected_mime, + ), + } } else { ( std::borrow::Cow::Borrowed(img.data.as_slice()), @@ -1427,7 +1493,7 @@ impl SvgRenderer { bbox.x, bbox.y, bbox.width, bbox.height, data_uri, )); } - ImageFillMode::TileAll => { + ImageFillMode::TileAll | ImageFillMode::Total => { self.render_tiled_image(&render_bytes, &data_uri, bbox, true, true, None); } ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom => { @@ -1491,7 +1557,7 @@ impl SvgRenderer { // WMF → SVG 변환 (브라우저는 WMF를 렌더링할 수 없으므로 SVG로 변환) // BMP → PNG 변환 (브라우저는 SVG 내부의 data:image/bmp 미지원) - // PCX → PNG 변환 (브라우저는 PCX 포맷을 native 렌더링하지 못함, Task #514) + // PCX/TIFF → PNG 변환 (브라우저는 두 포맷을 native 렌더링하지 못함) let (render_data, render_mime, baked_watermark): (std::borrow::Cow<[u8]>, &str, bool) = if preserve_color_watermark { match real_picture_watermark_fill_bytes_to_hancom_tone_png_bytes(data) { @@ -1513,11 +1579,21 @@ impl SvgRenderer { Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png", false), None => (std::borrow::Cow::Borrowed(data), mime_type, false), } + } else if mime_type == "image/tiff" { + match tiff_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png", false), + None => (std::borrow::Cow::Borrowed(data), mime_type, false), + } } else if is_watermark_image && mime_type == "image/jpeg" { match watermark_jpeg_bytes_to_hancom_baked_png_bytes(data) { Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png", true), None => (std::borrow::Cow::Borrowed(data), mime_type, false), } + } else if mime_type == "image/jpeg" { + match grayscale_jpeg_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png", false), + None => (std::borrow::Cow::Borrowed(data), mime_type, false), + } } else { (std::borrow::Cow::Borrowed(data), mime_type, false) }; @@ -1618,7 +1694,7 @@ impl SvgRenderer { )); } } - ImageFillMode::TileAll => { + ImageFillMode::TileAll | ImageFillMode::Total => { // 바둑판식으로-모두: 원래 크기로 전체 타일링 self.render_tiled_image( &render_data, @@ -1973,10 +2049,8 @@ impl SvgRenderer { escape_xml(&font_family_str), inner_font_size ); - if style.is_visually_bold() { - font_attrs.push_str(" font-weight=\"bold\""); - } else if style.is_medium_weight() { - font_attrs.push_str(" font-weight=\"500\""); + if let Some(weight) = style.css_font_weight() { + font_attrs.push_str(&format!(" font-weight=\"{}\"", weight)); } if style.italic { font_attrs.push_str(" font-style=\"italic\""); @@ -2117,10 +2191,8 @@ impl SvgRenderer { escape_xml(&font_family_str), inner_font_size ); - if style.is_visually_bold() { - font_attrs.push_str(" font-weight=\"bold\""); - } else if style.is_medium_weight() { - font_attrs.push_str(" font-weight=\"500\""); + if let Some(weight) = style.css_font_weight() { + font_attrs.push_str(&format!(" font-weight=\"{}\"", weight)); } if style.italic { font_attrs.push_str(" font-style=\"italic\""); @@ -2630,6 +2702,13 @@ impl Renderer for SvgRenderer { width, height, width, height, )); self.defs_insert_pos = self.output.len(); + // HWP/PDF pages are opaque white by default. Some pages have no explicit + // PageBackground node; without this, SVG-to-PNG tools composite the + // transparent page against black and produce false visual diffs. + self.output.push_str(&format!( + "\n", + width, height + )); } fn end_page(&mut self) { @@ -2688,10 +2767,8 @@ impl Renderer for SvgRenderer { escape_xml(&font_family), font_size, ); - if style.is_visually_bold() { - base_attrs.push_str(" font-weight=\"bold\""); - } else if style.is_medium_weight() { - base_attrs.push_str(" font-weight=\"500\""); + if let Some(weight) = style.css_font_weight() { + base_attrs.push_str(&format!(" font-weight=\"{}\"", weight)); } if style.italic { base_attrs.push_str(" font-style=\"italic\""); @@ -3188,6 +3265,11 @@ impl Renderer for SvgRenderer { Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), None => (std::borrow::Cow::Borrowed(data), mime_type), } + } else if mime_type == "image/tiff" { + match tiff_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } } else { (std::borrow::Cow::Borrowed(data), mime_type) }; diff --git a/src/renderer/svg/tests.rs b/src/renderer/svg/tests.rs index 7d5aa3eaa..53af629b9 100644 --- a/src/renderer/svg/tests.rs +++ b/src/renderer/svg/tests.rs @@ -8,6 +8,9 @@ fn test_svg_begin_end_page() { let output = renderer.output(); assert!(output.starts_with("") + ); assert!(output.ends_with("\n")); } @@ -248,6 +251,33 @@ fn test_bmp_to_png_invalid_returns_none() { assert!(bmp_bytes_to_png_bytes(&junk).is_none()); } +/// 최소 2x1 RGBA TIFF를 생성한다 (테스트용). +fn make_minimal_tiff_2x1() -> Vec { + let img = image::RgbaImage::from_raw( + 2, + 1, + vec![ + 220, 220, 220, 255, // + 255, 255, 255, 255, + ], + ) + .expect("valid RGBA image"); + let mut out = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Tiff, + ) + .expect("TIFF encode"); + out +} + +#[test] +fn test_tiff_to_png_success() { + let tiff = make_minimal_tiff_2x1(); + let png = tiff_bytes_to_png_bytes(&tiff).expect("TIFF->PNG 변환 실패"); + assert!(png.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])); +} + /// 최소 2x1 8-bit paletted PCX를 생성한다 (테스트용). fn make_minimal_pcx_2x1() -> Vec { let mut header = [0u8; 128]; @@ -306,6 +336,26 @@ fn test_page_background_image_pcx_converts_to_png() { assert!(!output.contains("data:image/x-pcx")); } +#[test] +fn test_page_background_image_tiff_converts_to_png() { + let image = PageBackgroundImage { + data: make_minimal_tiff_2x1(), + fill_mode: ImageFillMode::FitToSize, + brightness: 0, + contrast: 0, + effect: crate::model::image::ImageEffect::RealPic, + }; + let bbox = BoundingBox::new(10.0, 20.0, 100.0, 50.0); + let mut renderer = SvgRenderer::new(); + renderer.begin_page(200.0, 100.0); + + renderer.render_page_background_image(&image, &bbox); + + let output = renderer.output(); + assert!(output.contains("data:image/png;base64,iVBORw0KGgo")); + assert!(!output.contains("data:image/tiff")); +} + #[test] fn test_page_background_image_fit_to_size_preserves_bbox_output() { let png = bmp_bytes_to_png_bytes(&make_minimal_bmp_2x2()).expect("BMP->PNG 변환 실패"); @@ -331,6 +381,30 @@ fn test_page_background_image_fit_to_size_preserves_bbox_output() { ); } +#[test] +fn test_image_fill_total_uses_native_brush_pattern() { + let png = bmp_bytes_to_png_bytes(&make_minimal_bmp_2x2()).expect("BMP->PNG 변환 실패"); + let mut image = ImageNode::new(1, Some(png)); + image.fill_mode = Some(ImageFillMode::Total); + let bbox = BoundingBox::new(10.0, 20.0, 100.0, 4.0); + let mut renderer = SvgRenderer::new(); + renderer.begin_page(200.0, 100.0); + + renderer.render_image_node(&image, &bbox); + + let output = renderer.output(); + assert!( + output.contains("fill=\"url(#tile-pat-"), + "TOTAL image brush should render as native-size pattern: {output}" + ); + assert!( + !output.contains( + "PNG 변환 실패"); diff --git a/src/renderer/typeset.rs b/src/renderer/typeset.rs index 37b332d5e..dd34fd53f 100644 --- a/src/renderer/typeset.rs +++ b/src/renderer/typeset.rs @@ -30,8 +30,9 @@ use crate::renderer::{ // [Task #836] 미주 paragraph의 가상 para_index = paragraphs.len() + endnote 내 순번. // rendering.rs에서 paragraphs + endnote_paragraphs를 합쳐서 전달. use super::pagination::{ - ColumnContent, EndnoteParaSource, EndnoteRef, FootnoteRef, FootnoteSource, HeaderFooterRef, - PageContent, PageItem, PaginationResult, + estimate_footnote_note_height, footnote_between_notes_margin_px, + footnote_separator_overhead_px, ColumnContent, EndnoteParaSource, EndnoteRef, FootnoteRef, + FootnoteSource, HeaderFooterRef, PageContent, PageItem, PaginationResult, }; fn note_number_format_from_hwp_code(code: u8) -> RenderNumberFormat { @@ -117,6 +118,8 @@ struct FormattedTable { cells: Vec, /// 표 셀 내 각주 높이 합계 (가용 높이에서 차감) table_footnote_height: f64, + /// 표 셀 내 각주 수 (separator/between-notes 예약 계산용) + table_footnote_count: usize, } #[derive(Debug, Clone, Copy)] @@ -176,6 +179,8 @@ struct TypesetState { is_first_footnote_on_page: bool, /// 각주 구분선 오버헤드 footnote_separator_overhead: f64, + /// 각주 사이 간격 + footnote_between_notes_margin: f64, /// 각주 안전 여백 footnote_safety_margin: f64, /// 존(zone) y 오프셋 (다단 나누기 시 누적) @@ -462,6 +467,27 @@ fn page_item_para_index(item: &PageItem) -> Option { } } +fn page_item_vpos_base(item: &PageItem, paragraphs: &[Paragraph]) -> Option { + match item { + PageItem::PartialParagraph { + para_index, + start_line, + .. + } => paragraphs + .get(*para_index) + .and_then(|para| para.line_segs.get(*start_line)) + .map(|seg| seg.vertical_pos), + PageItem::FullParagraph { para_index } + | PageItem::Table { para_index, .. } + | PageItem::PartialTable { para_index, .. } + | PageItem::Shape { para_index, .. } => paragraphs + .get(*para_index) + .and_then(|para| para.line_segs.first()) + .map(|seg| seg.vertical_pos), + PageItem::EndnoteSeparator { .. } => None, + } +} + fn square_picture_wrap_anchor_for_para( st: &TypesetState, body_paragraphs: &[Paragraph], @@ -960,6 +986,69 @@ fn is_synthetic_line_seg(ls: &LineSeg) -> bool { ls.tag & 0x80000000 != 0 } +fn paragraph_saved_vpos_reset_starts_new_page_after( + current_para: &Paragraph, + next_para: &Paragraph, + col_count: u16, + is_hwp3_variant: bool, +) -> bool { + let next_first_vpos = next_para.line_segs.first().map(|s| s.vertical_pos); + let curr_last_vpos = current_para.line_segs.last().map(|s| s.vertical_pos); + let multi_col = col_count > 1; + let allowed_top_vpos = if is_hwp3_variant { 1500 } else { 0 }; + + matches!((next_first_vpos, curr_last_vpos), (Some(nv), Some(cl)) + if (if multi_col { nv < cl } else { nv <= allowed_top_vpos }) && cl > 5000) +} + +fn paragraph_forces_page_boundary_after( + current_para: &Paragraph, + next_para: &Paragraph, + col_count: u16, + is_hwp3_variant: bool, +) -> bool { + matches!( + next_para.column_type, + ColumnBreakType::Page | ColumnBreakType::Section + ) || paragraph_saved_vpos_reset_starts_new_page_after( + current_para, + next_para, + col_count, + is_hwp3_variant, + ) +} + +fn single_line_visible_bounds_px( + para: &Paragraph, + page_vpos_base: i32, + dpi: f64, +) -> Option<(f64, f64)> { + let mut real_lines = para + .line_segs + .iter() + .filter(|ls| !is_synthetic_line_seg(ls)); + let line = real_lines.next()?; + if real_lines.next().is_some() { + return None; + } + + line_seg_visible_bounds_px(line, page_vpos_base, dpi) +} + +fn line_seg_visible_bounds_px(seg: &LineSeg, page_vpos_base: i32, dpi: f64) -> Option<(f64, f64)> { + let top = seg.vertical_pos.saturating_sub(page_vpos_base); + let bottom = seg + .vertical_pos + .saturating_add(seg.line_height) + .saturating_sub(page_vpos_base); + (top >= 0 && bottom >= 0).then(|| (hwpunit_to_px(top, dpi), hwpunit_to_px(bottom, dpi))) +} + +fn saved_bounds_fit_at_flow_tail(bounds: (f64, f64), current_height: f64, available: f64) -> bool { + let (top, bottom) = bounds; + top + 16.0 >= current_height && bottom <= available + 0.5 +} + fn positive_vpos_end_before_negative_wrap(para: &Paragraph) -> Option { let last_real = para .line_segs @@ -983,6 +1072,7 @@ impl TypesetState { col_count: u16, section_index: usize, footnote_separator_overhead: f64, + footnote_between_notes_margin: f64, footnote_safety_margin: f64, column_type: ColumnType, ) -> Self { @@ -1000,6 +1090,7 @@ impl TypesetState { current_footnote_height: 0.0, is_first_footnote_on_page: true, footnote_separator_overhead, + footnote_between_notes_margin, footnote_safety_margin, current_zone_y_offset: 0.0, current_zone_layout: None, @@ -1068,8 +1159,41 @@ impl TypesetState { if self.is_first_footnote_on_page { self.current_footnote_height += self.footnote_separator_overhead; self.is_first_footnote_on_page = false; + } else { + self.current_footnote_height += self.footnote_between_notes_margin; } self.current_footnote_height += height; + self.sync_current_page_footnote_area(); + } + + fn projected_footnote_height(&self, note_content_height: f64, note_count: usize) -> f64 { + if note_count == 0 { + return self.current_footnote_height; + } + let separator = if self.is_first_footnote_on_page { + self.footnote_separator_overhead + } else { + 0.0 + }; + let between_count = if self.is_first_footnote_on_page { + note_count.saturating_sub(1) + } else { + note_count + }; + self.current_footnote_height + + separator + + self.footnote_between_notes_margin * between_count as f64 + + note_content_height + } + + fn sync_current_page_footnote_area(&mut self) { + if self.current_footnote_height <= 0.0 { + return; + } + if let Some(page) = self.pages.last_mut() { + page.layout + .update_footnote_area(self.current_footnote_height); + } } /// 현재 항목을 ColumnContent로 만들어 마지막 페이지에 push @@ -1575,6 +1699,7 @@ impl TypesetEngine { false, false, None, + None, force_break_before, false, ) @@ -1601,6 +1726,7 @@ impl TypesetEngine { is_hwp3_variant: bool, skip_spacing_before_prededuct: bool, hwp3_origin_page_tolerance: bool, + footnote_shape: Option<&FootnoteShape>, endnote_shape: Option<&FootnoteShape>, force_break_before: &std::collections::HashSet, is_hwpx_source: bool, @@ -1608,7 +1734,11 @@ impl TypesetEngine { let layout = PageLayoutInfo::from_page_def(page_def, column_def, self.dpi); self.is_hwpx_source.set(is_hwpx_source); let col_count = column_def.column_count.max(1); - let footnote_separator_overhead = hwpunit_to_px(400, self.dpi); + let default_footnote_shape = FootnoteShape::default(); + let footnote_shape = footnote_shape.unwrap_or(&default_footnote_shape); + let footnote_separator_overhead = footnote_separator_overhead_px(footnote_shape, self.dpi); + let footnote_between_notes_margin = + footnote_between_notes_margin_px(footnote_shape, self.dpi); let footnote_safety_margin = hwpunit_to_px(3000, self.dpi); // [Task #1007] variant cross-paragraph vpos reset THRESHOLD 계산용 body height (HU) let body_height_hu_for_variant: i32 = if is_hwp3_variant { @@ -1630,6 +1760,7 @@ impl TypesetEngine { col_count, section_index, footnote_separator_overhead, + footnote_between_notes_margin, footnote_safety_margin, column_def.column_type, ); @@ -2014,15 +2145,14 @@ impl TypesetEngine { if next_force_break { false } else { - let next_first_vpos = next_para.line_segs.first().map(|s| s.vertical_pos); - let curr_last_vpos = para.line_segs.last().map(|s| s.vertical_pos); // [Task #470] 다단 섹션에서는 nv == 0 → nv < cl 로 완화 (컬럼 헤더 오프셋). // 단일 단에서는 partial-table split 회귀 (issue #418) 회피 위해 nv == 0 유지. - let multi_col = st.col_count > 1; - let allowed_top_vpos = if st.is_hwp3_variant { 1500 } else { 0 }; - matches!((next_first_vpos, curr_last_vpos), (Some(nv), Some(cl)) - if (if multi_col { nv < cl } else { nv <= allowed_top_vpos }) - && cl > 5000) + paragraph_saved_vpos_reset_starts_new_page_after( + para, + next_para, + st.col_count, + st.is_hwp3_variant, + ) } } else { false @@ -2626,7 +2756,7 @@ impl TypesetEngine { tb_control_index: tc_idx, }, }); - let fn_height = Self::estimate_footnote_height( + let fn_height = estimate_footnote_note_height( fn_ctrl, self.dpi, ); st.add_footnote_height(fn_height); @@ -2713,7 +2843,7 @@ impl TypesetEngine { }, }); } - let fn_height = Self::estimate_footnote_height(fn_ctrl, self.dpi); + let fn_height = estimate_footnote_note_height(fn_ctrl, self.dpi); st.add_footnote_height(fn_height); } } @@ -8839,68 +8969,6 @@ impl TypesetEngine { st.apply_visible_float_exclusions(exclusion_probe_height); let available = (st.available_height() - safety).max(0.0); - // Task #321 Stage 1 진단: 포맷터 총 높이 vs LINE_SEG 실측 총 높이 비교 - // Stage 5a 확장: per-paragraph 카테고리 분해 (sb/sa/lines/line_sum/ls_sum) - if std::env::var("RHWP_TYPESET_DRIFT").is_ok() { - let vpos_h: Option = if let (Some(first), Some(last)) = - (para.line_segs.first(), para.line_segs.last()) - { - let span_hu = - (last.vertical_pos + last.line_height + last.line_spacing) - first.vertical_pos; - if span_hu > 0 { - Some(crate::renderer::hwpunit_to_px(span_hu, self.dpi)) - } else { - None - } - } else { - None - }; - let first_vpos = para.line_segs.first().map(|s| s.vertical_pos).unwrap_or(-1); - let last_vpos = para.line_segs.last().map(|s| s.vertical_pos).unwrap_or(-1); - let lh_sum: f64 = fmt.line_heights.iter().sum(); - let ls_sum: f64 = fmt.line_spacings.iter().sum(); - let line_count = fmt.line_heights.len(); - let trailing_ls = fmt.line_spacings.last().copied().unwrap_or(0.0); - let diff = match vpos_h { - Some(v) => fmt.total_height - v, - None => 0.0, - }; - let vpos_h_str = vpos_h - .map(|v| format!("{:.1}", v)) - .unwrap_or_else(|| "-".to_string()); - eprintln!( - "TYPESET_DRIFT_PI: pi={} col={} sb={:.1} sa={:.1} lines={} lh_sum={:.1} ls_sum={:.1} trail_ls={:.1} fmt_total={:.1} vpos_h={} diff={:+.1} first_vpos={} last_vpos={} cur_h={:.1} avail={:.1}", - para_idx, st.current_column, fmt.spacing_before, fmt.spacing_after, - line_count, lh_sum, ls_sum, trailing_ls, - fmt.total_height, vpos_h_str, diff, - first_vpos, last_vpos, - st.current_height, available, - ); - - // 옵션: per-line 분해 (LINE_SEG 와 비교) - if std::env::var("RHWP_TYPESET_DRIFT_LINES").is_ok() { - for (li, (lh, ls)) in fmt - .line_heights - .iter() - .zip(fmt.line_spacings.iter()) - .enumerate() - { - let seg = para.line_segs.get(li); - let seg_lh = seg - .map(|s| crate::renderer::hwpunit_to_px(s.line_height, self.dpi)) - .unwrap_or(-1.0); - let seg_ls = seg - .map(|s| crate::renderer::hwpunit_to_px(s.line_spacing, self.dpi)) - .unwrap_or(-1.0); - let seg_vpos = seg.map(|s| s.vertical_pos).unwrap_or(-1); - eprintln!( - "TYPESET_DRIFT_LINE: pi={} li={} fmt_lh={:.1} fmt_ls={:.1} seg_lh={:.1} seg_ls={:.1} vpos={}", - para_idx, li, lh, ls, seg_lh, seg_ls, seg_vpos, - ); - } - } - } - // 다단 레이아웃에서 문단 내 단 경계 감지 // [Task #459] on_first_multicolumn_page 가드 제거: 다단 구역이 여러 페이지에 걸칠 때 // 후속 페이지에서도 LINE_SEG vpos-reset 으로 인코딩된 단 경계를 인식해야 함. @@ -8955,6 +9023,10 @@ impl TypesetEngine { let total_h = st.current_height + fmt.height_for_fit; let fit_fail_within_safety = total_h > available && total_h <= available + LAYOUT_DRIFT_SAFETY_PX; + let base_available = st.base_available_height() - st.current_zone_y_offset; + let fit_fail_only_after_footnote_reserve = st.current_footnote_height > 0.0 + && total_h > available + && total_h <= base_available; let prior_trailing_drift = st.current_height > available && st.current_height <= available + LAYOUT_DRIFT_SAFETY_PX + 0.5; let previous_item_is_empty_para = st @@ -8974,7 +9046,7 @@ impl TypesetEngine { st.hidden_empty_paras.insert(para_idx); return; } - if fit_fail_within_safety { + if fit_fail_within_safety || fit_fail_only_after_footnote_reserve { st.current_items.push(PageItem::FullParagraph { para_index: para_idx, }); @@ -9011,7 +9083,27 @@ impl TypesetEngine { .last() .map(|s| s.vertical_pos + s.line_height + s.line_spacing); - if forced_page_break_line.is_none() && st.current_height + fmt.height_for_fit <= available { + let current_page_vpos_base = st.vpos_page_base.or_else(|| { + st.current_items + .first() + .and_then(|item| page_item_vpos_base(item, paragraphs)) + }); + let saved_single_line_bottom_fits = forced_page_break_line.is_none() + && st.col_count == 1 + && fmt.line_heights.len() == 1 + && fmt.spacing_after <= 0.5 + && para.controls.is_empty() + && !st.current_items.is_empty() + && current_page_vpos_base + .and_then(|base| single_line_visible_bounds_px(para, base, self.dpi)) + .is_some_and(|bounds| { + saved_bounds_fit_at_flow_tail(bounds, st.current_height, st.available_height()) + }); + + if forced_page_break_line.is_none() + && (st.current_height + fmt.height_for_fit <= available + || saved_single_line_bottom_fits) + { // place: 전체 배치 st.current_items.push(PageItem::FullParagraph { para_index: para_idx, @@ -9084,6 +9176,83 @@ impl TypesetEngine { } } + // [Task #1537] 폰트 치환 drift 로 인한 "tail 1줄 spill 후 강제 쪽나누기 고아 페이지" 차단. + // + // 증상: 본문 문단 N 이 페이지 하단을 ~한 줄 미만으로 미세 초과(폰트 치환으로 부피가 + // 한컴 대비 커짐)하여 마지막 줄만 새 페이지로 split → 그 직후 문단 N+1 이 명시적 + // 쪽나누기(column_type==Page/Section)를 가지면 또 새 페이지를 강제 → spill 한 1줄이 + // 거의 빈 페이지에 고립된다(2025 행정업무운영 편람: 0-idx page 11/13/17, 본문 1줄+빈공간). + // + // 한컴은 폰트 drift 가 없어 문단 N 전체를 현재 페이지에 담고 N+1 의 쪽나누기로 깔끔히 + // 다음 페이지를 시작한다. 우리도 "초과량이 한 줄 미만(=drift)이고 다음 문단이 어차피 + // 쪽나누기로 페이지를 끝낸다"는 두 조건이 모두 맞을 때만 문단 N 을 통째로 현재 페이지에 + // 배치(하단 여백으로 소량 bleed 허용)해 고아 페이지를 제거한다. 일반 본문 흐름(다음 + // 문단이 쪽나누기가 아님)이나 초과량이 한 줄 이상(진짜 split 필요)인 경우는 불변. + + // 다음 문단이 쪽/구역 나누기인가? 사이에 빈 문단(텍스트·컨트롤 없음)이 끼어 있으면 + // 건너뛴다 — 빈 문단은 높이를 거의 차지하지 않고 hide_empty_line 로 흡수되므로, + // "tail spill → 빈 문단 → 강제 쪽나누기" 패턴에서도 spill 한 줄이 동일하게 고립된다. + // 단, 텍스트/컨트롤이 있는 일반 문단을 만나면 즉시 중단(false) — 그 문단이 + // 현재 페이지를 마저 채우므로 고아 페이지가 생기지 않는다. + let next_para_forces_break = { + let mut idx = para_idx + 1; + let mut prior_para = para; + let mut forced = false; + while let Some(next_para) = paragraphs.get(idx) { + if paragraph_forces_page_boundary_after( + prior_para, + next_para, + st.col_count, + st.is_hwp3_variant, + ) { + forced = true; + break; + } + let is_empty = next_para.text.trim().is_empty() && next_para.controls.is_empty(); + if !is_empty { + break; + } + prior_para = next_para; + idx += 1; + } + forced + }; + // 본문 높이를 바꾸지 않는 컨트롤(각주/미주)만 허용 — 표/그림/글상자가 있으면 + // 줄 단위 split/배치 규칙이 달라지므로 제외. + let only_note_controls = para + .controls + .iter() + .all(|c| matches!(c, Control::Footnote(_) | Control::Endnote(_))); + if st.col_count == 1 + && forced_page_break_line.is_none() + && next_para_forces_break + && !para.text.trim().is_empty() + && only_note_controls + && !st.current_items.is_empty() + && fmt.line_heights.len() >= 2 + { + let first_line_advance = fmt.line_advance(0); + // 다음 문단이 어차피 쪽나누기로 페이지를 끝내므로, 다음 페이지 layout clamp 를 + // 막으려던 LAYOUT_DRIFT_SAFETY_PX(현재 페이지 한정) 여유는 이 경우 의미가 없다. + // 따라서 safety 를 뺀 `available` 이 아니라 진짜 본문 하단(각주/존 차감 포함)인 + // available_height() 를 기준으로 초과량을 잰다. + let true_available = st.available_height(); + // 초과량이 한 줄 미만(폰트 drift)일 때만 통째 배치. + // (full-place 체크를 이미 통과 못 했으므로 overflow > -safety. 진짜 본문 하단 + // 기준으로 한 줄 미만 초과면 마지막 줄 spill 대신 통째 배치.) + let overflow = st.current_height + fmt.height_for_fit - true_available; + if overflow < first_line_advance { + st.current_items.push(PageItem::FullParagraph { + para_index: para_idx, + }); + st.current_height += fmt.total_height; + if let Some(v) = body_bottom_vpos { + st.prev_body_bottom_vpos = Some(v); + } + return; + } + } + // split: 줄 단위 분할 let line_count = fmt.line_heights.len(); if line_count == 0 { @@ -9314,24 +9483,6 @@ impl TypesetEngine { // Phase 2: Break Token 기반 표 조판 // ======================================================== - /// 단일 각주의 높이를 추정한다 (HeightMeasurer::estimate_single_footnote_height 동일). - fn estimate_footnote_height(footnote: &crate::model::footnote::Footnote, dpi: f64) -> f64 { - let mut fn_height = 0.0; - for para in &footnote.paragraphs { - if para.line_segs.is_empty() { - fn_height += hwpunit_to_px(400, dpi); - } else { - for seg in ¶.line_segs { - fn_height += hwpunit_to_px(seg.line_height, dpi); - } - } - } - if fn_height <= 0.0 { - fn_height = hwpunit_to_px(400, dpi); - } - fn_height - } - /// 표의 조판 높이를 계산한다 (format 단계). /// MeasuredTable + host_spacing을 통합하여 layout과 동일한 규칙으로 계산. #[allow(clippy::too_many_arguments)] @@ -9495,18 +9646,14 @@ impl TypesetEngine { // 표 셀 내 각주 높이 사전 계산 (Paginator engine.rs:565-581 동일) let mut table_footnote_height = 0.0; - let mut table_has_footnotes = false; + let mut table_footnote_count = 0usize; for cell in &table.cells { for cp in &cell.paragraphs { for cc in &cp.controls { if let Control::Footnote(fn_ctrl) = cc { - let fn_height = Self::estimate_footnote_height(fn_ctrl, self.dpi); - if !table_has_footnotes { - // 첫 각주 시 구분선 오버헤드 추가 여부는 호출 시점의 상태에 의존 - // 여기서는 순수 각주 높이만 누적 (구분선은 typeset_block_table에서 처리) - } + let fn_height = estimate_footnote_note_height(fn_ctrl, self.dpi); table_footnote_height += fn_height; - table_has_footnotes = true; + table_footnote_count += 1; } } } @@ -9525,6 +9672,7 @@ impl TypesetEngine { page_break, cells, table_footnote_height, + table_footnote_count, } } @@ -9582,12 +9730,32 @@ impl TypesetEngine { } else { fmt.total_height }; + let saved_single_tac_bottom_fits = if has_tac && tac_count <= 1 { + para.controls + .iter() + .find_map(|ctrl| match ctrl { + Control::Table(table) if self.is_effective_tac_table(para, table, &fmt) => { + Some(self.tac_table_line_index(para, table, &fmt).unwrap_or(0)) + } + _ => None, + }) + .and_then(|line_idx| para.line_segs.get(line_idx)) + .and_then(|seg| { + line_seg_visible_bounds_px(seg, st.vpos_page_base.unwrap_or(0), self.dpi) + }) + .is_some_and(|bounds| { + saved_bounds_fit_at_flow_tail(bounds, st.current_height, st.available_height()) + }) + } else { + false + }; // 넘치면 flush (단일 TAC 표만) if st.current_height + height_for_fit > st.available_height() && !st.current_items.is_empty() && has_tac && tac_count <= 1 + && !saved_single_tac_bottom_fits { st.advance_column_or_new_page(); } @@ -9800,7 +9968,7 @@ impl TypesetEngine { }); } let fn_height = - Self::estimate_footnote_height(fn_ctrl, self.dpi); + estimate_footnote_note_height(fn_ctrl, self.dpi); st.add_footnote_height(fn_height); } } @@ -10013,13 +10181,8 @@ impl TypesetEngine { let lane_top = lanes.pushed_top(x_start, x_end, raw_top); let lane_bottom = lane_top + reserved_height; - let table_footnote = ft.table_footnote_height; - let fn_separator = if table_footnote > 0.0 && st.is_first_footnote_on_page { - st.footnote_separator_overhead - } else { - 0.0 - }; - let total_footnote = st.current_footnote_height + table_footnote + fn_separator; + let total_footnote = + st.projected_footnote_height(ft.table_footnote_height, ft.table_footnote_count); let fn_margin = if total_footnote > 0.0 { st.footnote_safety_margin } else { @@ -10078,19 +10241,22 @@ impl TypesetEngine { } let tac_table_line_idx = self.tac_table_line_index(para, table, fmt); - // 다중 TAC 표: LINE_SEG 기반 개별 높이 계산 - let table_height = if tac_count > 1 { - let tac_idx = para - .controls + let tac_seg_idx = if tac_count > 1 { + para.controls .iter() .take(ctrl_idx) .filter( |c| matches!(c, Control::Table(t) if self.is_effective_tac_table(para, t, fmt)), ) - .count(); - let is_last_tac = tac_idx + 1 == tac_count; + .count() + } else { + tac_table_line_idx.unwrap_or(0) + }; + // 다중 TAC 표: LINE_SEG 기반 개별 높이 계산 + let table_height = if tac_count > 1 { + let is_last_tac = tac_seg_idx + 1 == tac_count; para.line_segs - .get(tac_idx) + .get(tac_seg_idx) .map(|seg| { let line_h = hwpunit_to_px(seg.line_height, self.dpi); if is_last_tac { @@ -10121,7 +10287,20 @@ impl TypesetEngine { // TAC 표는 분할하지 않고 통째로 배치 let available = st.available_height(); - if st.current_height + table_height > available && !st.current_items.is_empty() { + let current_page_vpos_base = st.vpos_page_base.unwrap_or(0); + let saved_tac_table_bottom_fits = Some(current_page_vpos_base) + .and_then(|base| { + para.line_segs + .get(tac_seg_idx) + .and_then(|seg| line_seg_visible_bounds_px(seg, base, self.dpi)) + }) + .is_some_and(|bounds| { + saved_bounds_fit_at_flow_tail(bounds, st.current_height, available) + }); + if st.current_height + table_height > available + && !saved_tac_table_bottom_fits + && !st.current_items.is_empty() + { st.advance_column_or_new_page(); } @@ -10396,13 +10575,8 @@ impl TypesetEngine { is_last_placed: bool, ) { // 표 내 각주를 고려한 가용 높이 계산 (Paginator engine.rs:583-586 동일) - let table_fn_h = ft.table_footnote_height; - let fn_separator = if table_fn_h > 0.0 && st.is_first_footnote_on_page { - st.footnote_separator_overhead - } else { - 0.0 - }; - let total_footnote = st.current_footnote_height + table_fn_h + fn_separator; + let total_footnote = + st.projected_footnote_height(ft.table_footnote_height, ft.table_footnote_count); let fn_margin = if total_footnote > 0.0 { st.footnote_safety_margin } else { @@ -11821,7 +11995,11 @@ impl TypesetEngine { .. } => *para_index == *ph_para && *start_line == 0, PageItem::Table { para_index, .. } => *para_index == *ph_para, - PageItem::PartialTable { para_index, .. } => *para_index == *ph_para, + PageItem::PartialTable { + para_index, + is_continuation, + .. + } => *para_index == *ph_para && !*is_continuation, PageItem::Shape { para_index, .. } => *para_index == *ph_para, PageItem::EndnoteSeparator { .. } => false, }) @@ -12099,6 +12277,7 @@ mod tests { use crate::model::paragraph::{LineSeg, Paragraph}; use crate::renderer::composer::ComposedParagraph; use crate::renderer::height_measurer::HeightMeasurer; + use crate::renderer::page_layout::PageLayoutInfo; use crate::renderer::pagination::Paginator; use crate::renderer::style_resolver::ResolvedStyleSet; @@ -12127,6 +12306,37 @@ mod tests { } } + fn page_with_items(items: Vec) -> PageContent { + PageContent { + page_index: 0, + page_number: 0, + section_index: 0, + layout: PageLayoutInfo::from_page_def( + &a4_page_def(), + &ColumnDef::default(), + DEFAULT_DPI, + ), + column_contents: vec![ColumnContent { + column_index: 0, + start_height: 0.0, + endnote_flow: false, + items, + zone_layout: None, + zone_y_offset: 0.0, + wrap_around_paras: Vec::new(), + used_height: 0.0, + wrap_anchors: std::collections::HashMap::new(), + }], + active_header: None, + active_footer: None, + page_number_pos: None, + page_hide: None, + footnotes: Vec::new(), + active_master_page: None, + extra_master_pages: Vec::new(), + } + } + /// 두 PaginationResult의 페이지 수와 각 페이지의 항목 수가 동일한지 비교 fn assert_pagination_match(old: &PaginationResult, new: &PaginationResult, label: &str) { assert_eq!( @@ -12194,6 +12404,121 @@ mod tests { assert_eq!(result.pages.len(), 1, "빈 문서도 최소 1페이지"); } + #[test] + fn table_continuation_does_not_reapply_page_hide() { + let hide = crate::model::control::PageHide { + hide_master_page: true, + hide_page_num: true, + ..Default::default() + }; + let mut pages = vec![ + page_with_items(vec![PageItem::PartialTable { + para_index: 7, + control_index: 0, + start_row: 0, + end_row: 2, + is_continuation: false, + start_cut: Vec::new(), + end_cut: Vec::new(), + is_block_split: false, + }]), + page_with_items(vec![PageItem::PartialTable { + para_index: 7, + control_index: 0, + start_row: 2, + end_row: 4, + is_continuation: true, + start_cut: Vec::new(), + end_cut: Vec::new(), + is_block_split: false, + }]), + ]; + + TypesetEngine::finalize_pages(&mut pages, &[], &None, &[], &[(7, hide)], 0); + + assert!(pages[0].page_hide.is_some()); + assert!(pages[1].page_hide.is_none()); + } + + #[test] + fn footnote_area_reserve_uses_section_shape_metrics() { + let engine = TypesetEngine::with_default_dpi(); + let styles = ResolvedStyleSet::default(); + let shape = FootnoteShape { + separator_margin_top: 1000, + note_spacing: 700, + raw_unknown: 900, + separator_line_width: 4, + ..Default::default() + }; + let note1 = Paragraph { + text: "첫 각주".to_string(), + line_segs: vec![LineSeg { + line_height: 400, + ..Default::default() + }], + ..Default::default() + }; + let note2 = Paragraph { + text: "둘째 각주".to_string(), + line_segs: vec![LineSeg { + line_height: 600, + ..Default::default() + }], + ..Default::default() + }; + let paras = vec![Paragraph { + text: "본문".to_string(), + line_segs: vec![LineSeg { + line_height: 400, + ..Default::default() + }], + controls: vec![ + Control::Footnote(Box::new(crate::model::footnote::Footnote { + number: 1, + paragraphs: vec![note1], + ..Default::default() + })), + Control::Footnote(Box::new(crate::model::footnote::Footnote { + number: 2, + paragraphs: vec![note2], + ..Default::default() + })), + ], + ..Default::default() + }]; + let composed: Vec = paras + .iter() + .map(crate::renderer::composer::compose_paragraph) + .collect(); + + let result = engine.typeset_section_with_variant( + ¶s, + &composed, + &styles, + &a4_page_def(), + &ColumnDef::default(), + 0, + &[], + false, + false, + false, + false, + Some(&shape), + None, + &std::collections::HashSet::new(), + false, + ); + + let expected = footnote_separator_overhead_px(&shape, DEFAULT_DPI) + + hwpunit_to_px(400, DEFAULT_DPI) + + footnote_between_notes_margin_px(&shape, DEFAULT_DPI) + + hwpunit_to_px(600, DEFAULT_DPI); + let page = result.pages.first().expect("page"); + assert_eq!(page.footnotes.len(), 2); + assert!((page.layout.footnote_area.height - expected).abs() < 0.01); + } + #[test] fn test_typeset_single_paragraph() { let engine = TypesetEngine::with_default_dpi(); @@ -12248,6 +12573,235 @@ mod tests { assert_pagination_match(&old_result, &new_result, "page_overflow"); } + #[test] + fn saved_single_line_at_body_bottom_stays_on_current_page() { + let engine = TypesetEngine::with_default_dpi(); + let mut styles = ResolvedStyleSet::default(); + styles + .para_styles + .push(crate::renderer::style_resolver::ResolvedParaStyle { + spacing_before: 9.3, + ..Default::default() + }); + let page_def = a4_page_def(); + let col_def = ColumnDef::default(); + let layout = PageLayoutInfo::from_page_def(&page_def, &col_def, DEFAULT_DPI); + let body_height_hu = + crate::renderer::px_to_hwpunit(layout.available_body_height(), DEFAULT_DPI); + let line_height = 1200; + let line_spacing = 840; + let spacing_before_hu = crate::renderer::px_to_hwpunit(9.3, DEFAULT_DPI); + let lead_height = body_height_hu - line_height; + let lead_measured_height = lead_height - spacing_before_hu + 600; + let paras = vec![ + Paragraph { + text: "lead".to_string(), + line_segs: vec![LineSeg { + vertical_pos: 0, + line_height: lead_measured_height, + text_height: lead_measured_height, + ..Default::default() + }], + ..Default::default() + }, + Paragraph { + para_shape_id: 0, + text: "tail".to_string(), + line_segs: vec![LineSeg { + vertical_pos: lead_height, + line_height, + text_height: line_height, + line_spacing, + ..Default::default() + }], + ..Default::default() + }, + ]; + let composed: Vec = Vec::new(); + + let result = engine.typeset_section( + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, + &[], + false, + &std::collections::HashSet::new(), + ); + + assert_eq!(result.pages.len(), 1); + assert_eq!(result.pages[0].column_contents[0].items.len(), 2); + } + + #[test] + fn two_line_tail_before_vpos_reset_stays_on_current_page_when_visible_bottom_fits() { + let engine = TypesetEngine::with_default_dpi(); + let mut styles = ResolvedStyleSet::default(); + styles + .para_styles + .push(crate::renderer::style_resolver::ResolvedParaStyle::default()); + styles + .para_styles + .push(crate::renderer::style_resolver::ResolvedParaStyle { + spacing_before: hwpunit_to_px(2400, DEFAULT_DPI), + spacing_after: hwpunit_to_px(1400, DEFAULT_DPI), + ..Default::default() + }); + + let page_def = a4_page_def(); + let col_def = ColumnDef::default(); + let layout = PageLayoutInfo::from_page_def(&page_def, &col_def, DEFAULT_DPI); + let body_height_hu = + crate::renderer::px_to_hwpunit(layout.available_body_height(), DEFAULT_DPI); + let line_height = 1200; + let line_spacing = 840; + let first_vpos = body_height_hu - 3740; + let lead_height = first_vpos - 2400; + + let paras = vec![ + Paragraph { + text: "lead".to_string(), + line_segs: vec![LineSeg { + vertical_pos: 0, + line_height: lead_height, + text_height: lead_height, + ..Default::default() + }], + ..Default::default() + }, + Paragraph { + para_shape_id: 1, + text: "line one\nline two".to_string(), + line_segs: vec![ + LineSeg { + vertical_pos: first_vpos, + line_height, + text_height: line_height, + line_spacing, + ..Default::default() + }, + LineSeg { + vertical_pos: first_vpos + line_height + line_spacing, + line_height, + text_height: line_height, + line_spacing, + text_start: 9, + ..Default::default() + }, + ], + ..Default::default() + }, + Paragraph { + text: "next page".to_string(), + line_segs: vec![LineSeg { + vertical_pos: 0, + line_height, + text_height: line_height, + ..Default::default() + }], + ..Default::default() + }, + ]; + let composed: Vec = Vec::new(); + + let result = engine.typeset_section( + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, + &[], + false, + &std::collections::HashSet::new(), + ); + + assert_eq!(result.pages.len(), 2); + assert!(matches!( + result.pages[0].column_contents[0].items.as_slice(), + [ + PageItem::FullParagraph { para_index: 0 }, + PageItem::FullParagraph { para_index: 1 } + ] + )); + assert!(matches!( + result.pages[1].column_contents[0].items.as_slice(), + [PageItem::FullParagraph { para_index: 2 }] + )); + } + + #[test] + fn saved_tac_table_line_at_body_bottom_stays_on_current_page() { + let engine = TypesetEngine::with_default_dpi(); + let styles = ResolvedStyleSet::default(); + let page_def = a4_page_def(); + let col_def = ColumnDef::default(); + let layout = PageLayoutInfo::from_page_def(&page_def, &col_def, DEFAULT_DPI); + let body_height_hu = + crate::renderer::px_to_hwpunit(layout.available_body_height(), DEFAULT_DPI); + let table_height = 10_072; + let table_vpos = body_height_hu - table_height; + let lead_height = table_vpos + 900; + let paras = vec![ + Paragraph { + text: "lead".to_string(), + line_segs: vec![LineSeg { + vertical_pos: 0, + line_height: lead_height, + text_height: lead_height, + ..Default::default() + }], + ..Default::default() + }, + Paragraph { + controls: vec![Control::Table(Box::new(crate::model::table::Table { + attr: 1, + row_count: 3, + col_count: 3, + common: crate::model::shape::CommonObjAttr { + treat_as_char: true, + text_wrap: crate::model::shape::TextWrap::TopAndBottom, + height: table_height as u32, + ..Default::default() + }, + ..Default::default() + }))], + line_segs: vec![LineSeg { + vertical_pos: table_vpos, + line_height: table_height, + text_height: table_height, + line_spacing: 120, + ..Default::default() + }], + ..Default::default() + }, + ]; + let composed: Vec = Vec::new(); + + let result = engine.typeset_section( + ¶s, + &composed, + &styles, + &page_def, + &col_def, + 0, + &[], + false, + &std::collections::HashSet::new(), + ); + + assert_eq!(result.pages.len(), 1); + assert!(matches!( + result.pages[0].column_contents[0].items.as_slice(), + [ + PageItem::FullParagraph { para_index: 0 }, + PageItem::Table { para_index: 1, .. } + ] + )); + } + /// [Task #1363 v3 Stage 2] scratch 측정 부작용 격리 회귀 가드. /// /// `measure_endnote_para_advance` 는 매 호출 `LayoutEngine::new()` 로 독립 인스턴스를 diff --git a/src/renderer/web_canvas.rs b/src/renderer/web_canvas.rs index b5dbf9dc9..1e15deb49 100644 --- a/src/renderer/web_canvas.rs +++ b/src/renderer/web_canvas.rs @@ -61,12 +61,49 @@ fn group_label_matches_replay_plane( None => true, } } + +fn is_kopub_dotum_light_face(font_family: &str) -> bool { + let primary = font_family + .split(',') + .next() + .unwrap_or(font_family) + .trim() + .trim_matches('\'') + .trim_matches('"'); + let lower = primary.to_ascii_lowercase(); + (primary.contains("KoPub돋움체") || lower.contains("kopub dotum")) + && (primary.contains("Light") || lower.contains("light")) +} + +fn canvas_generic_fallback(font_family: &str) -> &'static str { + if is_kopub_dotum_light_face(font_family) { + return "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans KR ExtraLight','Noto Sans KR','Pretendard','HCR Batang Ext-B','함초롬바탕 확장B','HCR Batang Ext','함초롬바탕 확장','HCR Batang','함초롬바탕','Source Han Serif K Old Hangul',sans-serif"; + } + super::generic_fallback(font_family) +} + +fn canvas_font_family(font_family: &str) -> String { + if font_family.is_empty() { + "sans-serif".to_string() + } else { + let fallback = canvas_generic_fallback(font_family); + format!("\"{}\", {}", font_family, fallback) + } +} + +fn canvas_css_font_weight(style: &TextStyle) -> Option<&'static str> { + if !style.bold && is_kopub_dotum_light_face(&style.font_family) { + None + } else { + style.css_font_weight() + } +} use super::composer::{ decode_pua_overlap_number, expand_pua_render_text, pua_to_display_text, CharOverlapInfo, }; use super::form_caption::display_form_caption; #[cfg(target_arch = "wasm32")] -use super::layout::{compute_char_positions, split_into_clusters}; +use super::layout::{compute_char_positions, font_family_has_metrics, split_into_clusters}; use crate::model::control::FormType; // 이미지 캐시: data 해시 → HtmlImageElement @@ -77,6 +114,68 @@ thread_local! { std::cell::RefCell::new(std::collections::HashMap::new()); } +// [Task: 임베디드 그림 첫-렌더 페인트] 동기 디코드 캔버스 캐시. +// +// 기존 draw_image 는 HtmlImageElement.set_src(data URL) 로 이미지를 비동기 +// 로드하므로, 페이지를 한 번만 renderPageToCanvas 하는 브라우저 뷰어에서는 +// img.complete()==false 라 첫 렌더에 그림이 그려지지 않는다 (재렌더가 없어 +// 영영 빈 칸). PNG/JPEG/BMP 는 image 크레이트로 Rust 측에서 즉시 디코드해 +// 오프스크린 HtmlCanvasElement 에 put_image_data 한 뒤, drawImage(canvas) +// 로 첫 렌더에 동기 페인트한다. 캐시 값 None = 디코드 불가(WMF/SVG 등) → +// 기존 HtmlImageElement 비동기 경로로 폴백. +#[cfg(target_arch = "wasm32")] +thread_local! { + static DECODED_CANVAS_CACHE: std::cell::RefCell< + std::collections::HashMap>, + > = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// 이미지 바이트를 RGBA 로 디코드해 오프스크린 캔버스로 만든다 (동기). +/// +/// image 크레이트가 디코드 가능한 포맷(PNG/JPEG/BMP/TIFF)만 처리. 실패하면 +/// None — 호출부는 기존 HtmlImageElement 비동기 경로로 폴백한다. +#[cfg(target_arch = "wasm32")] +fn decode_image_to_canvas(data: &[u8]) -> Option { + let dynimg = image::load_from_memory(data).ok()?; + let rgba = dynimg.to_rgba8(); + let (iw, ih) = (rgba.width(), rgba.height()); + if iw == 0 || ih == 0 { + return None; + } + let buf = rgba.into_raw(); + let image_data = + web_sys::ImageData::new_with_u8_clamped_array_and_sh(wasm_bindgen::Clamped(&buf), iw, ih) + .ok()?; + + let document = web_sys::window()?.document()?; + let canvas: HtmlCanvasElement = document + .create_element("canvas") + .ok()? + .dyn_into::() + .ok()?; + canvas.set_width(iw); + canvas.set_height(ih); + let ctx = canvas + .get_context("2d") + .ok()?? + .dyn_into::() + .ok()?; + ctx.put_image_data(&image_data, 0.0, 0.0).ok()?; + Some(canvas) +} + +#[cfg(target_arch = "wasm32")] +fn normalize_browser_image_bytes(data: &[u8]) -> std::borrow::Cow<'_, [u8]> { + if crate::renderer::image_resolver::detect_image_mime_type(data) == "image/jpeg" { + if let Some(png) = crate::renderer::image_resolver::grayscale_jpeg_bytes_to_png_bytes(data) + { + return std::borrow::Cow::Owned(png); + } + } + + std::borrow::Cow::Borrowed(data) +} + /// 빠른 해시 (FNV-1a 64비트) #[cfg(target_arch = "wasm32")] fn hash_bytes(data: &[u8]) -> u64 { @@ -103,6 +202,11 @@ fn detect_image_mime_type(data: &[u8]) -> &'static str { "image/x-icon" } else if data.len() >= 2 && &data[0..2] == b"BM" { "image/bmp" + } else if data.len() >= 4 + && (data.starts_with(&[0x49, 0x49, 0x2A, 0x00]) + || data.starts_with(&[0x4D, 0x4D, 0x00, 0x2A])) + { + "image/tiff" } else if data.len() >= 4 && (data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || data.starts_with(&[0x01, 0x00, 0x09, 0x00])) @@ -645,19 +749,16 @@ impl WebCanvasRenderer { } else if run.rotation != 0.0 { let cx = bbox.x + bbox.width / 2.0; let cy = bbox.y + bbox.height / 2.0; - let font_weight = if run.style.bold { "bold " } else { "" }; + let font_weight = canvas_css_font_weight(&run.style) + .map(|weight| format!("{} ", weight)) + .unwrap_or_default(); let font_style_str = if run.style.italic { "italic " } else { "" }; let font_size = if run.style.font_size > 0.0 { run.style.font_size } else { 12.0 }; - let font_family = if run.style.font_family.is_empty() { - "sans-serif".to_string() - } else { - let fallback = super::generic_fallback(&run.style.font_family); - format!("\"{}\" , {}", run.style.font_family, fallback) - }; + let font_family = canvas_font_family(&run.style.font_family); let font = format!( "{}{}{:.3}px {}", font_style_str, font_weight, font_size, font_family @@ -2023,7 +2124,9 @@ impl Renderer for WebCanvasRenderer { let text = &expand_pua_old_hangul_canvas(text); // 글꼴 설정 - let font_weight = if style.bold { "bold " } else { "" }; + let font_weight = canvas_css_font_weight(style) + .map(|weight| format!("{} ", weight)) + .unwrap_or_default(); let font_style = if style.italic { "italic " } else { "" }; let base_font_size = if style.font_size > 0.0 { style.font_size @@ -2040,12 +2143,7 @@ impl Renderer for WebCanvasRenderer { (base_font_size, y) }; - let font_family = if style.font_family.is_empty() { - "sans-serif".to_string() - } else { - let fallback = super::generic_fallback(&style.font_family); - format!("\"{}\", {}", style.font_family, fallback) - }; + let font_family = canvas_font_family(&style.font_family); let font = format!( "{}{}{:.3}px {}", @@ -2057,6 +2155,15 @@ impl Renderer for WebCanvasRenderer { let ratio = if style.ratio > 0.0 { style.ratio } else { 1.0 }; let has_ratio = (ratio - 1.0).abs() > 0.01; + // [치환 폰트 글리프 왜곡 방지] 요청 폰트가 내장 메트릭 DB 에 없으면 + // (= 브라우저가 다른 폰트로 치환) char_positions 의 advance 는 실제 + // 글리프 폭이 아닌 휴리스틱 폴백(0.5em 등)이다. 이 경우 ASCII 글리프를 + // advance 에 맞춰 글리프별 가로 스케일(pin_ascii_advance)하면 치환 폰트의 + // 좁은 글리프(l/i/t)가 과도하게 늘어난다. 치환 폰트에서는 글리프를 + // 자연 advance 그대로 그리고, run 내 누적 x 도 측정 폭으로 재산출한다. + let font_substituted = + !font_family_has_metrics(&style.font_family, style.bold, style.italic); + // 클러스터 분할 let clusters = split_into_clusters(text); @@ -2179,10 +2286,13 @@ impl Renderer for WebCanvasRenderer { ); if needs_font_fallback { self.ctx.save(); + let fallback_weight = canvas_css_font_weight(style) + .map(|weight| format!("{weight} ")) + .unwrap_or_default(); let fallback_font = format!( "{}{}{:.3}px 'Malgun Gothic','맑은 고딕',sans-serif", if style.italic { "italic " } else { "" }, - if style.bold { "bold " } else { "" }, + fallback_weight, font_size ); self.ctx.set_font(&fallback_font); @@ -2211,9 +2321,16 @@ impl Renderer for WebCanvasRenderer { 0.0 } }; - let pin_ascii_advance = - cluster_str.chars().any(|ch| ch.is_ascii_alphanumeric()); - let fit_scale = if cluster_advance > 0.0 { + let is_ascii_alnum = cluster_str.chars().any(|ch| ch.is_ascii_alphanumeric()); + // [치환 폰트] ASCII 글리프를 휴리스틱 advance 에 맞춰 늘이는 + // pin_ascii_advance 를 끈다. char_positions 의 advance 가 실제 + // 글리프 폭이 아니라 0.5em 폴백이므로, 좁은 글리프(l/i/t)가 + // 2배까지 늘어나 왜곡되기 때문이다. + let pin_ascii_advance = !font_substituted && is_ascii_alnum; + // 치환 폰트의 ASCII 는 자연 폭 그대로 그린다 — 늘이지도(pin), + // 줄이지도(overflow shrink) 않아야 l/i/t 와 m/w 가 일관된다. + let skip_fit = font_substituted && is_ascii_alnum; + let fit_scale = if cluster_advance > 0.0 && !skip_fit { self.ctx .measure_text(cluster_str) .ok() @@ -2539,9 +2656,36 @@ impl Renderer for WebCanvasRenderer { } fn draw_image(&mut self, data: &[u8], x: f64, y: f64, w: f64, h: f64) { + let render_data = normalize_browser_image_bytes(data); + let data = render_data.as_ref(); let key = hash_bytes(data); - // 캐시에서 이미 로드된 이미지를 찾는다 + // [동기 페인트] PNG/JPEG/BMP 는 image 크레이트로 즉시 디코드한 오프스크린 + // 캔버스를 drawImage 로 첫 렌더에 그린다 (비동기 HtmlImageElement 가 + // 첫 렌더에 빈 칸이 되는 문제 회피). drawImage(canvas) 는 ctx 변환을 + // 존중하고 (x,y,w,h) 로 스케일한다. + let decoded = DECODED_CANVAS_CACHE.with(|cache| { + let mut c = cache.borrow_mut(); + if let Some(slot) = c.get(&key) { + return slot.clone(); + } + // 캐시 크기 제한 (최대 200개) + if c.len() > 200 { + c.clear(); + } + let canvas = decode_image_to_canvas(data); + c.insert(key, canvas.clone()); + canvas + }); + if let Some(canvas) = decoded { + let _ = self + .ctx + .draw_image_with_html_canvas_element_and_dw_and_dh(&canvas, x, y, w, h); + return; + } + + // 캐시에서 이미 로드된 이미지를 찾는다 (image 크레이트 미지원 포맷: + // WMF/SVG/PCX 등 → HtmlImageElement 비동기 경로) let cached = IMAGE_CACHE.with(|cache| { let c = cache.borrow(); c.get(&key).cloned() @@ -2560,7 +2704,7 @@ impl Renderer for WebCanvasRenderer { let mime_type = detect_image_mime_type(data); // WMF → SVG 변환 (브라우저는 WMF를 렌더링할 수 없으므로 SVG로 변환) - // PCX → PNG 변환 (브라우저는 PCX 포맷을 native 렌더링하지 못함, Task #514) + // PCX/TIFF → PNG 변환 (브라우저는 두 포맷을 native 렌더링하지 못함) let (render_data, render_mime): (std::borrow::Cow<[u8]>, &str) = if mime_type == "image/x-wmf" { match crate::renderer::svg::convert_wmf_to_svg(data) { @@ -2572,6 +2716,16 @@ impl Renderer for WebCanvasRenderer { Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), None => (std::borrow::Cow::Borrowed(data), mime_type), } + } else if mime_type == "image/tiff" { + match crate::renderer::image_resolver::tiff_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } + } else if mime_type == "image/jpeg" { + match crate::renderer::image_resolver::grayscale_jpeg_bytes_to_png_bytes(data) { + Some(png_bytes) => (std::borrow::Cow::Owned(png_bytes), "image/png"), + None => (std::borrow::Cow::Borrowed(data), mime_type), + } } else { (std::borrow::Cow::Borrowed(data), mime_type) }; @@ -2630,8 +2784,33 @@ impl WebCanvasRenderer { dw: f64, dh: f64, ) { + let render_data = normalize_browser_image_bytes(data); + let data = render_data.as_ref(); let key = hash_bytes(data); + // [동기 페인트] PNG/JPEG/BMP 는 디코드된 오프스크린 캔버스로 첫 렌더에 + // crop 적용. (draw_image 와 동일 캐시 공유) + let decoded = DECODED_CANVAS_CACHE.with(|cache| { + let mut c = cache.borrow_mut(); + if let Some(slot) = c.get(&key) { + return slot.clone(); + } + if c.len() > 200 { + c.clear(); + } + let canvas = decode_image_to_canvas(data); + c.insert(key, canvas.clone()); + canvas + }); + if let Some(canvas) = decoded { + let _ = self + .ctx + .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + &canvas, sx, sy, sw, sh, dx, dy, dw, dh, + ); + return; + } + let cached = IMAGE_CACHE.with(|cache| { let c = cache.borrow(); c.get(&key).cloned() @@ -2820,13 +2999,10 @@ impl WebCanvasRenderer { glyph_color.clone() }; - let font_family = if style.font_family.is_empty() { - "sans-serif".to_string() - } else { - let fallback = super::generic_fallback(&style.font_family); - format!("\"{}\" , {}", style.font_family, fallback) - }; - let font_weight = if style.bold { "bold " } else { "" }; + let font_family = canvas_font_family(&style.font_family); + let font_weight = canvas_css_font_weight(style) + .map(|weight| format!("{} ", weight)) + .unwrap_or_default(); let font_style_str = if style.italic { "italic " } else { "" }; let font = format!( "{}{}{:.3}px {}", @@ -2986,12 +3162,7 @@ impl WebCanvasRenderer { glyph_color.clone() }; - let font_family = if style.font_family.is_empty() { - "sans-serif".to_string() - } else { - let fallback = super::generic_fallback(&style.font_family); - format!("\"{}\" , {}", style.font_family, fallback) - }; + let font_family = canvas_font_family(&style.font_family); let cx = bbox_x + box_size / 2.0; let cy = bbox_y + bbox_h - box_size / 2.0; @@ -3031,7 +3202,9 @@ impl WebCanvasRenderer { 1.0 }; - let font_weight = if style.bold { "bold " } else { "" }; + let font_weight = canvas_css_font_weight(style) + .map(|weight| format!("{} ", weight)) + .unwrap_or_default(); let font_style_str = if style.italic { "italic " } else { "" }; let font = format!( "{}{}{:.3}px {}", @@ -3258,6 +3431,7 @@ impl WebCanvasRenderer { bbox.y + bbox.height - img_height, ), ImageFillMode::TileAll + | ImageFillMode::Total | ImageFillMode::TileHorzTop | ImageFillMode::TileHorzBottom | ImageFillMode::TileVertLeft @@ -3272,7 +3446,7 @@ impl WebCanvasRenderer { self.ctx.clip(); match mode { - ImageFillMode::TileAll => { + ImageFillMode::TileAll | ImageFillMode::Total => { // 바둑판식으로-모두: 전체 타일링 let mut ty = bbox.y; while ty < bbox.y + bbox.height { diff --git a/src/serializer/control.rs b/src/serializer/control.rs index b996032f8..cc0f3b74a 100644 --- a/src/serializer/control.rs +++ b/src/serializer/control.rs @@ -2168,6 +2168,7 @@ fn serialize_shape_fill(w: &mut ByteWriter, fill: &Fill) { ImageFillMode::TileHorzBottom => 2, ImageFillMode::TileVertLeft => 3, ImageFillMode::TileVertRight => 4, + ImageFillMode::Total => 0, ImageFillMode::FitToSize => 5, ImageFillMode::Center => 6, ImageFillMode::CenterTop => 7, diff --git a/src/serializer/doc_info.rs b/src/serializer/doc_info.rs index 9cf4e1ead..5a456b33c 100644 --- a/src/serializer/doc_info.rs +++ b/src/serializer/doc_info.rs @@ -290,6 +290,7 @@ fn image_fill_mode_to_u8(mode: ImageFillMode) -> u8 { ImageFillMode::TileHorzBottom => 2, ImageFillMode::TileVertLeft => 3, ImageFillMode::TileVertRight => 4, + ImageFillMode::Total => 0, ImageFillMode::FitToSize => 5, ImageFillMode::Center => 6, ImageFillMode::CenterTop => 7, diff --git a/src/serializer/hwpx/mod.rs b/src/serializer/hwpx/mod.rs index be6de69cb..81629ef51 100644 --- a/src/serializer/hwpx/mod.rs +++ b/src/serializer/hwpx/mod.rs @@ -27,6 +27,7 @@ pub mod utils; pub mod writer; use std::collections::HashSet; +use std::fmt::Write as _; use crate::model::document::Document; @@ -111,8 +112,12 @@ pub fn serialize_hwpx(doc: &Document) -> Result, SerializeError> { .unwrap_or_else(|| SETTINGS_XML.as_bytes()), )?; - // 7. META-INF/container.rdf - z.write_deflated("META-INF/container.rdf", META_INF_CONTAINER_RDF.as_bytes())?; + // 7. META-INF/container.rdf — header + every section part. + // Hancom uses this RDF graph alongside content.hpf; a stale one-section + // RDF makes multi-section documents fail to open even when the ZIP and + // content.hpf contain every section. + let container_rdf = write_container_rdf(§ion_hrefs); + z.write_deflated("META-INF/container.rdf", container_rdf.as_bytes())?; // 8. BinData ZIP 엔트리 (Stage 4) // `ctx.bin_data_map` 의 엔트리 순서대로 실제 바이너리를 ZIP에 추가. @@ -170,6 +175,41 @@ pub fn serialize_hwpx(doc: &Document) -> Result, SerializeError> { z.finish() } +fn write_container_rdf(section_hrefs: &[String]) -> String { + const PKG_NS: &str = "http://www.hancom.co.kr/hwpml/2016/meta/pkg#"; + + let mut out = String::new(); + out.push_str(r#""#); + out.push_str(r#""#); + out.push_str(r#""#); + let _ = write!( + out, + r#""# + ); + out.push_str(r#""#); + out.push_str(r#""#); + let _ = write!(out, r#""#); + out.push_str(r#""#); + + for href in section_hrefs { + out.push_str(r#""#); + let _ = write!( + out, + r#""# + ); + out.push_str(r#""#); + let _ = write!(out, r#""#); + let _ = write!(out, r#""#); + out.push_str(r#""#); + } + + out.push_str(r#""#); + let _ = write!(out, r#""#); + out.push_str(r#""#); + out.push_str(r#""#); + out +} + /// 3-way BinData 동기화 단언: `ctx.bin_data_entries()`, content.hpf manifest, /// ZIP entry 의 href 집합이 모두 일치하는지 확인. fn assert_bin_data_3way( @@ -190,6 +230,8 @@ fn assert_bin_data_3way( #[cfg(test)] mod tests { + use std::io::Read; + use super::*; use crate::parser::hwpx::parse_hwpx; @@ -255,6 +297,158 @@ mod tests { assert_eq!(parsed.sections.len(), 1); } + #[test] + fn master_pages_are_serialized_as_package_parts() { + use crate::model::document::Section; + use crate::model::header_footer::{HeaderFooterApply, MasterPage}; + use crate::model::paragraph::Paragraph; + use crate::serializer::hwpx::package_check::check_package; + + let mut doc = Document::default(); + let mut section0 = Section::default(); + let mut section1 = Section::default(); + + let mut first_master_para = Paragraph::default(); + first_master_para.text = "first master".to_string(); + section0.section_def.master_pages.push(MasterPage { + apply_to: HeaderFooterApply::Both, + text_width: 10_000, + text_height: 10_000, + text_ref: 1, + paragraphs: vec![first_master_para], + ..Default::default() + }); + + let mut second_master_para = Paragraph::default(); + second_master_para.text = "second master".to_string(); + section1.section_def.master_pages.push(MasterPage { + apply_to: HeaderFooterApply::Odd, + text_width: 12_000, + text_height: 8_000, + text_ref: 1, + paragraphs: vec![second_master_para], + ..Default::default() + }); + + doc.sections.push(section0); + doc.sections.push(section1); + + let bytes = serialize_hwpx(&doc).expect("serialize master pages"); + let report = check_package(&bytes, &doc); + assert!(report.is_ok(), "problems: {}", report.summary()); + + let cursor = std::io::Cursor::new(&bytes); + let mut archive = zip::ZipArchive::new(cursor).expect("zip"); + for name in ["Contents/masterpage0.xml", "Contents/masterpage1.xml"] { + archive + .by_name(name) + .unwrap_or_else(|_| panic!("missing {name}")); + } + + let mut content_hpf = String::new(); + archive + .by_name("Contents/content.hpf") + .expect("content.hpf") + .read_to_string(&mut content_hpf) + .expect("read content.hpf"); + assert!(content_hpf.contains(r#"id="masterpage0""#)); + assert!(content_hpf.contains(r#"href="Contents/masterpage1.xml""#)); + + let mut section1_xml = String::new(); + archive + .by_name("Contents/section1.xml") + .expect("section1") + .read_to_string(&mut section1_xml) + .expect("read section1"); + assert!(section1_xml.contains(r#"masterPageCnt="1""#)); + assert!(section1_xml.contains(r#"idRef="masterpage1""#)); + + drop(archive); + let parsed = parse_hwpx(&bytes).expect("parse back"); + assert_eq!(parsed.sections[0].section_def.master_pages.len(), 1); + assert_eq!(parsed.sections[1].section_def.master_pages.len(), 1); + assert_eq!( + parsed.sections[1].section_def.master_pages[0].apply_to, + HeaderFooterApply::Odd + ); + } + + #[test] + fn container_rdf_lists_every_section() { + let mut doc = Document::default(); + for _ in 0..3 { + doc.sections + .push(crate::model::document::Section::default()); + } + + let bytes = serialize_hwpx(&doc).expect("serialize"); + let cursor = std::io::Cursor::new(&bytes); + let mut archive = zip::ZipArchive::new(cursor).expect("zip"); + let mut container_rdf = String::new(); + archive + .by_name("META-INF/container.rdf") + .expect("container.rdf") + .read_to_string(&mut container_rdf) + .expect("read container.rdf"); + + assert!(container_rdf.contains(r#"rdf:resource="Contents/header.xml""#)); + for i in 0..3 { + let href = format!("Contents/section{i}.xml"); + assert!( + container_rdf.contains(&format!(r#"rdf:resource="{href}""#)), + "missing hasPart for {href}: {container_rdf}" + ); + assert!( + container_rdf.contains(&format!(r#"rdf:about="{href}""#)), + "missing type description for {href}: {container_rdf}" + ); + } + } + + #[test] + fn header_footer_ids_are_preserved() { + use crate::model::control::Control; + use crate::model::document::Section; + use crate::model::header_footer::{Footer, Header, HeaderFooterApply}; + use crate::model::paragraph::Paragraph; + + let mut doc = Document::default(); + let mut section = Section::default(); + let mut para = Paragraph::default(); + + para.controls.push(Control::Footer(Box::new(Footer { + raw_ctrl_extra: 2u32.to_le_bytes().to_vec(), + apply_to: HeaderFooterApply::Even, + ..Default::default() + }))); + para.controls.push(Control::Header(Box::new(Header { + raw_ctrl_extra: 1u32.to_le_bytes().to_vec(), + apply_to: HeaderFooterApply::Odd, + ..Default::default() + }))); + section.paragraphs.push(para); + doc.sections.push(section); + + let bytes = serialize_hwpx(&doc).expect("serialize header/footer ids"); + let cursor = std::io::Cursor::new(&bytes); + let mut archive = zip::ZipArchive::new(cursor).expect("zip"); + let mut section0_xml = String::new(); + archive + .by_name("Contents/section0.xml") + .expect("section0") + .read_to_string(&mut section0_xml) + .expect("read section0"); + + assert!( + section0_xml.contains(r#""#), + "footer id not preserved: {section0_xml}" + ); + assert!( + section0_xml.contains(r#""#), + "header id not preserved: {section0_xml}" + ); + } + #[test] fn serialize_text_paragraph_roundtrip() { let mut doc = Document::default(); diff --git a/src/serializer/hwpx/package_check.rs b/src/serializer/hwpx/package_check.rs index 203f05ea1..ad50bc467 100644 --- a/src/serializer/hwpx/package_check.rs +++ b/src/serializer/hwpx/package_check.rs @@ -9,7 +9,8 @@ //! META-INF/container.xml·container.rdf·manifest.xml) //! 4. `Contents/section{N}.xml` 엔트리 수 = IR 섹션 수 (잉여 섹션 엔트리 금지) //! 5. `Contents/content.hpf` manifest 가 참조하는 href 가 모두 ZIP 에 실재 -//! 6. `BinData/` 엔트리 수·확장자 멀티셋 = IR `bin_data_content` 보존 +//! 6. `Contents/masterpage{N}.xml` 엔트리·manifest·section idRef = IR 바탕쪽 보존 +//! 7. `BinData/` 엔트리 수·확장자 멀티셋 = IR `bin_data_content` 보존 //! //! 주의: serializer 가 BinData href 를 `BinData/image{N}.{ext}` 로 재명명하므로 //! 원본 ZIP 의 엔트리 **이름**이 아니라 IR 기준 **수·확장자**를 보존 기준으로 삼는다. @@ -126,23 +127,46 @@ pub fn check_package(hwpx_bytes: &[u8], doc: &Document) -> PackageCheckReport { } // 5. content.hpf manifest href 실재 확인 - match read_entry_string(&mut archive, "Contents/content.hpf") { + let content_hpf = match read_entry_string(&mut archive, "Contents/content.hpf") { Ok(hpf) => { for href in extract_hrefs(&hpf) { if !names.contains(href.as_str()) { report.push(format!("content.hpf 참조 엔트리 누락: {href}")); } } + Some(hpf) } Err(e) => { // 필수 엔트리 검사에서 이미 누락 보고됐을 수 있으므로 읽기 실패만 기록 if names.contains("Contents/content.hpf") { report.push(format!("content.hpf 읽기 실패: {e}")); } + None + } + }; + + // 5b. container.rdf section graph coverage. + // Hancom checks this package graph separately from content.hpf; stale RDF + // can make otherwise ZIP-valid multi-section HWPX exports fail to open. + match read_entry_string(&mut archive, "META-INF/container.rdf") { + Ok(rdf) => check_container_rdf(&mut report, &rdf, doc), + Err(e) => { + if names.contains("META-INF/container.rdf") { + report.push(format!("container.rdf 읽기 실패: {e}")); + } } } - // 6. BinData 수·확장자 보존 (IR 기준) + // 6. 바탕쪽(masterpage) 엔트리·manifest·section idRef 보존. + check_master_pages( + &mut report, + &mut archive, + &names, + content_hpf.as_deref(), + doc, + ); + + // 7. BinData 수·확장자 보존 (IR 기준) let zip_bin: Vec<&String> = names.iter().filter(|n| n.starts_with("BinData/")).collect(); if zip_bin.len() != doc.bin_data_content.len() { report.push(format!( @@ -177,6 +201,115 @@ fn is_section_entry_name(name: &str) -> bool { .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) } +/// `Contents/masterpage{숫자}.xml` 형태인지 확인. +fn is_master_page_entry_name(name: &str) -> bool { + name.strip_prefix("Contents/masterpage") + .and_then(|rest| rest.strip_suffix(".xml")) + .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) +} + +fn check_master_pages( + report: &mut PackageCheckReport, + archive: &mut zip::ZipArchive>, + names: &HashSet, + content_hpf: Option<&str>, + doc: &Document, +) { + let expected_count: usize = doc + .sections + .iter() + .map(|section| section.section_def.master_pages.len()) + .sum(); + let zip_count = names + .iter() + .filter(|n| is_master_page_entry_name(n)) + .count(); + if zip_count != expected_count { + report.push(format!( + "바탕쪽 엔트리 수 불일치: zip={} ir={}", + zip_count, expected_count + )); + } + + let mut global = 0usize; + for (section_idx, section) in doc.sections.iter().enumerate() { + let section_master_count = section.section_def.master_pages.len(); + let ids: Vec = (0..section_master_count) + .map(|offset| format!("masterpage{}", global + offset)) + .collect(); + + for id in &ids { + let href = format!("Contents/{id}.xml"); + if !names.contains(&href) { + report.push(format!("바탕쪽 엔트리 누락: {href}")); + } + if let Some(hpf) = content_hpf { + if !hpf.contains(&format!(r#"id="{id}""#)) + || !hpf.contains(&format!(r#"href="{href}""#)) + { + report.push(format!("content.hpf 바탕쪽 manifest 누락: {id} -> {href}")); + } + } + } + + if section_master_count > 0 { + let section_href = format!("Contents/section{section_idx}.xml"); + match read_entry_string(archive, §ion_href) { + Ok(section_xml) => { + let expected_cnt = format!(r#"masterPageCnt="{section_master_count}""#); + if !section_xml.contains(&expected_cnt) { + report.push(format!( + "section{section_idx} masterPageCnt 불일치: expected {section_master_count}" + )); + } + for id in &ids { + let expected_ref = format!(r#"idRef="{id}""#); + if !section_xml.contains(&expected_ref) { + report.push(format!("section{section_idx} 바탕쪽 idRef 누락: {id}")); + } + } + } + Err(e) => { + if names.contains(§ion_href) { + report.push(format!("{section_href} 읽기 실패: {e}")); + } + } + } + } + + global += section_master_count; + } +} + +fn check_container_rdf(report: &mut PackageCheckReport, rdf: &str, doc: &Document) { + if !rdf.contains(r#"rdf:resource="Contents/header.xml""#) + || !rdf.contains(r#"rdf:about="Contents/header.xml""#) + { + report.push("container.rdf header 참조 누락".to_string()); + } + + for i in 0..doc.sections.len() { + let href = format!("Contents/section{i}.xml"); + if !rdf.contains(&format!(r#"rdf:resource="{href}""#)) + || !rdf.contains(&format!(r#"rdf:about="{href}""#)) + { + report.push(format!("container.rdf 섹션 참조 누락: {href}")); + } + } + + let rdf_section_count = extract_rdf_resources(rdf) + .iter() + .filter(|href| is_section_entry_name(href)) + .count(); + if rdf_section_count != doc.sections.len() { + report.push(format!( + "container.rdf 섹션 참조 수 불일치: rdf={} ir={}", + rdf_section_count, + doc.sections.len() + )); + } +} + /// ZIP 엔트리를 문자열로 읽는다. fn read_entry_string( archive: &mut zip::ZipArchive>, @@ -206,6 +339,21 @@ fn extract_hrefs(xml: &str) -> Vec { hrefs } +fn extract_rdf_resources(xml: &str) -> Vec { + let mut resources = Vec::new(); + let mut rest = xml; + while let Some(pos) = rest.find("rdf:resource=\"") { + rest = &rest[pos + "rdf:resource=\"".len()..]; + if let Some(end) = rest.find('"') { + resources.push(rest[..end].to_string()); + rest = &rest[end + 1..]; + } else { + break; + } + } + resources +} + /// 파일명에서 소문자 확장자 추출 (없으면 빈 문자열). fn extension_lower(name: &str) -> String { name.rsplit_once('.') @@ -294,6 +442,60 @@ mod tests { ); } + #[test] + fn master_page_package_parts_are_required_when_ir_has_master_pages() { + use crate::model::document::Section; + use crate::model::header_footer::{HeaderFooterApply, MasterPage}; + use crate::model::paragraph::Paragraph; + + let mut doc = Document::default(); + let mut section = Section::default(); + let mut master_para = Paragraph::default(); + master_para.text = "master".to_string(); + section.section_def.master_pages.push(MasterPage { + apply_to: HeaderFooterApply::Both, + text_width: 10_000, + text_height: 10_000, + text_ref: 1, + paragraphs: vec![master_para], + ..Default::default() + }); + doc.sections.push(section); + + let bytes = serialize_hwpx(&doc).expect("serialize"); + let report = check_package(&bytes, &doc); + assert!(report.is_ok(), "problems: {}", report.summary()); + + let missing_master_bytes = serialize_hwpx(&doc_with_sections(1)).expect("serialize stale"); + let report = check_package(&missing_master_bytes, &doc); + assert!(!report.is_ok()); + assert!( + report.summary().contains("바탕쪽 엔트리 수 불일치") + && report + .summary() + .contains("content.hpf 바탕쪽 manifest 누락") + && report.summary().contains("section0 masterPageCnt 불일치"), + "summary: {}", + report.summary() + ); + } + + #[test] + fn detects_container_rdf_section_coverage_mismatch() { + let stale_bytes = serialize_hwpx(&doc_with_sections(1)).expect("serialize stale"); + let expected_doc = doc_with_sections(2); + let report = check_package(&stale_bytes, &expected_doc); + assert!(!report.is_ok()); + assert!( + report.summary().contains("container.rdf 섹션 참조 누락") + && report + .summary() + .contains("container.rdf 섹션 참조 수 불일치"), + "summary: {}", + report.summary() + ); + } + #[test] fn rejects_non_zip_bytes() { let doc = Document::default(); @@ -313,6 +515,10 @@ mod tests { assert!(!is_section_entry_name("Contents/section.xml")); assert!(!is_section_entry_name("Contents/sectionA.xml")); assert!(!is_section_entry_name("Contents/header.xml")); + assert!(is_master_page_entry_name("Contents/masterpage0.xml")); + assert!(is_master_page_entry_name("Contents/masterpage12.xml")); + assert!(!is_master_page_entry_name("Contents/masterpage.xml")); + assert!(!is_master_page_entry_name("Contents/masterpageA.xml")); } #[test] @@ -326,4 +532,16 @@ mod tests { ] ); } + + #[test] + fn extract_rdf_resources_basic() { + let xml = r#""#; + assert_eq!( + extract_rdf_resources(xml), + vec![ + "pkg#Document".to_string(), + "Contents/section0.xml".to_string() + ] + ); + } } diff --git a/src/serializer/hwpx/section.rs b/src/serializer/hwpx/section.rs index d16417ee6..ea40131d9 100644 --- a/src/serializer/hwpx/section.rs +++ b/src/serializer/hwpx/section.rs @@ -1119,12 +1119,13 @@ fn render_header_footer( ) -> String { let mut out = format!( concat!( - r#""#, + r#""#, r#""# ), tag = tag, + id = h.id, apply = apply_page_type_to_str(h.apply_to), tw = h.text_width, th = h.text_height, @@ -1146,6 +1147,7 @@ fn render_header_footer( /// render_header_footer 공통 인자 묶음 (Header/Footer가 동일 필드를 가짐). struct HeaderFooterFields<'a> { + id: u32, apply_to: HeaderFooterApply, text_width: u32, text_height: u32, @@ -1154,10 +1156,18 @@ struct HeaderFooterFields<'a> { paragraphs: &'a [Paragraph], } +fn hwpx_header_footer_id(raw_ctrl_extra: &[u8]) -> u32 { + raw_ctrl_extra + .get(..4) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .unwrap_or(0) +} + fn render_header(h: &Header, ctx: &mut SerializeContext) -> String { render_header_footer( "header", HeaderFooterFields { + id: hwpx_header_footer_id(&h.raw_ctrl_extra), apply_to: h.apply_to, text_width: h.text_width, text_height: h.text_height, @@ -1173,6 +1183,7 @@ fn render_footer(f: &Footer, ctx: &mut SerializeContext) -> String { render_header_footer( "footer", HeaderFooterFields { + id: hwpx_header_footer_id(&f.raw_ctrl_extra), apply_to: f.apply_to, text_width: f.text_width, text_height: f.text_height, diff --git a/src/serializer/hwpx/shape.rs b/src/serializer/hwpx/shape.rs index a7cb67051..89f42fce7 100644 --- a/src/serializer/hwpx/shape.rs +++ b/src/serializer/hwpx/shape.rs @@ -735,6 +735,7 @@ pub(crate) fn write_fill_brush( let img = fill.image.clone().unwrap_or_default(); let mode = match img.fill_mode { ImageFillMode::FitToSize => "FIT", + ImageFillMode::Total => "TOTAL", ImageFillMode::Center => "CENTER", _ => "TILE", }; diff --git a/tests/golden_svg/issue-267/ktx-toc-page.svg b/tests/golden_svg/issue-267/ktx-toc-page.svg index 3d904db11..3460fea15 100644 --- a/tests/golden_svg/issue-267/ktx-toc-page.svg +++ b/tests/golden_svg/issue-267/ktx-toc-page.svg @@ -3,7 +3,7 @@ - + @@ -16,14 +16,14 @@ - - - - - - - - + + + + + + + + diff --git a/tests/golden_svg/issue-617/exam-kor-page5.svg b/tests/golden_svg/issue-617/exam-kor-page5.svg index 7c8dae2fc..3724dc8cc 100644 --- a/tests/golden_svg/issue-617/exam-kor-page5.svg +++ b/tests/golden_svg/issue-617/exam-kor-page5.svg @@ -1,32 +1,32 @@ - - - - - - - - - - - - + + + + + + + + + + + + -6 +6 - + - - + + - + 1 @@ -67,16 +67,16 @@ 3 ] - -< + +< > - - - - + + + + @@ -1879,7 +1879,7 @@ . - + diff --git a/tests/issue_937.rs b/tests/issue_937.rs index 0519792df..4f8e5bd21 100644 --- a/tests/issue_937.rs +++ b/tests/issue_937.rs @@ -129,6 +129,34 @@ fn issue_937_f081c_filler_should_not_render_as_text() { ); } +#[test] +fn issue_937_f02fc_callout_bullet_should_render_as_pointer() { + assert_eq!( + expand_pua_render_text("\u{F02FC} 전자서명"), + "► 전자서명", + "U+F02FC 한컴 PUA callout bullet 는 missing glyph 대신 right pointer 로 표시되어야 함", + ); + assert_eq!( + pua_to_display_text('\u{F02FC}').as_deref(), + Some("►"), + "CharOverlap/display helper 도 같은 U+F02FC 표시 문자열을 반환해야 함", + ); +} + +#[test] +fn issue_937_f031c_toc_bullet_should_render_as_square() { + assert_eq!( + expand_pua_render_text("\u{F031C} 행정업무"), + "■ 행정업무", + "U+F031C 한컴 PUA TOC bullet 는 missing glyph 대신 black square 로 표시되어야 함", + ); + assert_eq!( + pua_to_display_text('\u{F031C}').as_deref(), + Some("■"), + "CharOverlap/display helper 도 같은 U+F031C 표시 문자열을 반환해야 함", + ); +} + #[test] fn issue_937_svg_renders_f012b_as_signature_seal() { let bytes = read_bokhakwonseo(); diff --git a/tests/render_p22_web_canvas_contract.rs b/tests/render_p22_web_canvas_contract.rs index 7497b857d..4dcaf09b5 100644 --- a/tests/render_p22_web_canvas_contract.rs +++ b/tests/render_p22_web_canvas_contract.rs @@ -48,3 +48,21 @@ fn web_canvas_control_code_group_labels_follow_active_replay_plane() { "layer group labels should use the replay-plane gate" ); } + +#[test] +fn web_canvas_kopub_light_uses_canvas_specific_font_guard() { + assert!( + WEB_CANVAS_SOURCE.contains("fn canvas_css_font_weight("), + "WebCanvas should own the browser-canvas font-weight override" + ); + assert!( + WEB_CANVAS_SOURCE.contains("is_kopub_dotum_light_face(&style.font_family)"), + "KoPub Dotum Light should not inherit the generic 300-weight hint in canvas" + ); + assert!( + WEB_CANVAS_SOURCE.contains( + "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo','Noto Sans KR ExtraLight'" + ), + "KoPub Dotum Light canvas fallback should prefer system Korean sans before ExtraLight" + ); +} diff --git a/tests/visual_roundtrip_baseline.rs b/tests/visual_roundtrip_baseline.rs index c97758afc..c8c24e5f6 100644 --- a/tests/visual_roundtrip_baseline.rs +++ b/tests/visual_roundtrip_baseline.rs @@ -35,13 +35,8 @@ const VISUAL_XFAIL: &[(&str, &str)] = &[ // () 직렬화 정정으로 다수 PASS 승격됨. // 잔여: k-water-rfp(대형 복합). ("k-water-rfp.hwpx", "구조 불일치 2페이지(대형, 복합)"), - // opengov 실문서 말뭉치(Task #1564) 신규 편입분은 #1567 직렬화 정정으로 구조 불일치 3건 - // 모두 PASS 승격됨. 잔여: 36392900(라운드트립 변위 드리프트 — base serializer 와 동일, - // PR #1586 무관 / 본질 정정은 별도 이슈). - ( - "opengov/36392900_결재문서본문_일일굴착복구공사현황보고.hwpx", - "최대 변위 1.32px > 임계 0.5px (직렬화 라운드트립 드리프트)", - ), + // opengov 실문서 말뭉치(Task #1564) 신규 편입분은 #1567 직렬화 정정과 + // 본 PR 렌더러 정합성 정정으로 모두 PASS 승격됨. ]; /// 검사 제외 — 샘플 자체가 HWPX 패키지가 아님(HWP5 가 .hwpx 확장자로 저장됨).