From 8946715384c28788f056710bcbc7e7bcb189538d Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 22:28:40 +0900 Subject: [PATCH 1/2] fix: refuse incomplete anchor-prepass results (#426) The floating-table body-anchor prepass returned a flat Vec whose entries all started at BodyAnchor::default(), so a paragraph the layout never visited was indistinguishable from a legitimate page-top anchor (v_pos 0, starts_page false), and the prepass's own budget diagnostics were discarded. synthesized_body_anchors now returns BodyAnchors: one Option slot per paragraph plus a completion result. A slot stays None when the paragraph was not visited, and completion is false when a page or display-item budget refused a page transition anywhere in the pass, including a floating-table continuation that stopped after every anchor had been recorded. The prepass keeps its private accumulator, so it neither spends the caller's budget nor repeats the document's warnings, and propagates only the diagnostics that invalidate an anchor. synthesize_linesegs publishes anchors only for a complete section. Otherwise that section falls back to the base cursor path it had before floating-table anchors existed and reports the new anchor_prepass_incomplete code once per section. Falling back rather than refusing keeps a hypothetical stop a position degradation instead of a failed render. Regression tests cover a complete pass (including a page-top anchor and its line flag), a stop during paragraph pagination, a stop during a floating-table continuation, and an unvisited later section. Every fixture paginates from font-free flow geometry, and the three incomplete-pass tests fail on the pre-fix consumption path at their default-zero or page-start assertion. --- CHANGELOG.md | 9 + crates/hwp-render/src/issues.rs | 12 +- crates/hwp-render/src/layout.rs | 111 ++++++- crates/hwp-render/src/lineseg.rs | 335 +++++++++++++++++++- docs/design/05-rendering.ko.md | 8 + docs/design/05-rendering.md | 27 ++ schemas/certification-report-v1.schema.json | 4 +- schemas/render-report-v1.schema.json | 5 +- 8 files changed, 486 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b7029c..e1fb6326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo another picture. Manifest precedence and native `BinRef::Id` compatibility are preserved ([#453](https://github.com/STAIxBWLB/hwp-cli/issues/453)). +- A floating-table section's line-layout prepass now reports whether it collected every + paragraph's anchor instead of returning default zero positions. An interrupted pass (a refused + page transition, or a paragraph it never reached) no longer publishes its empty entries as + measured anchors: that section falls back to the base cursor and reports the new + `anchor_prepass_incomplete` code once per section, with the prepass's own budget refusal + propagated as `layout_budget_exceeded`. Ordinary documents are unchanged, because the prepass + itself sets no budget + ([#426](https://github.com/STAIxBWLB/hwp-cli/issues/426)). + - A numbering template containing `<`, `>` or `&` now survives HWPX output. The HWPX reader resolves the XML entity references (`<`, `>`, `&`) inside `paraHead` text instead of dropping them, so `hwp compose` writes an ordered-list marker such as `<{n}>` escaped and diff --git a/crates/hwp-render/src/issues.rs b/crates/hwp-render/src/issues.rs index 43cc0813..13398538 100644 --- a/crates/hwp-render/src/issues.rs +++ b/crates/hwp-render/src/issues.rs @@ -106,6 +106,11 @@ pub enum RenderIssueCode { WmfUnsupportedRecordOmitted, /// WMF 해석의 레코드/객체/픽셀 상한 초과 → placeholder. WmfBudgetExceeded, + /// The body-anchor prepass for a floating-table section stopped before it collected every + /// paragraph's anchor (a page or display-item budget stop, or an unvisited paragraph). The + /// section falls back to the measured base cursor rather than publishing default zero + /// anchors as measured positions (#426). + AnchorPrepassIncomplete, } impl RenderIssueCode { @@ -114,7 +119,7 @@ impl RenderIssueCode { /// added here as well. The exhaustive match in `all_lists_every_code` fails to compile until /// the new variant has a match arm there, which is the prompt to add it here; the match arm /// does not itself force this entry. - pub const ALL: [RenderIssueCode; 35] = [ + pub const ALL: [RenderIssueCode; 36] = [ Self::ParseBudgetExceeded, Self::RenderExecutionFailed, Self::PaginationDriftDetected, @@ -150,6 +155,7 @@ impl RenderIssueCode { Self::WmfParseInvalidPlaceholder, Self::WmfUnsupportedRecordOmitted, Self::WmfBudgetExceeded, + Self::AnchorPrepassIncomplete, ]; pub const fn as_str(self) -> &'static str { @@ -189,6 +195,7 @@ impl RenderIssueCode { Self::WmfParseInvalidPlaceholder => "wmf_parse_invalid_placeholder", Self::WmfUnsupportedRecordOmitted => "wmf_unsupported_record_omitted", Self::WmfBudgetExceeded => "wmf_budget_exceeded", + Self::AnchorPrepassIncomplete => "anchor_prepass_incomplete", } } @@ -615,7 +622,8 @@ mod tests { | RenderIssueCode::LayoutBudgetExceeded | RenderIssueCode::WmfParseInvalidPlaceholder | RenderIssueCode::WmfUnsupportedRecordOmitted - | RenderIssueCode::WmfBudgetExceeded => RenderIssueCode::ALL.contains(&code), + | RenderIssueCode::WmfBudgetExceeded + | RenderIssueCode::AnchorPrepassIncomplete => RenderIssueCode::ALL.contains(&code), } } for (index, code) in RenderIssueCode::ALL.iter().enumerate() { diff --git a/crates/hwp-render/src/layout.rs b/crates/hwp-render/src/layout.rs index 9e10b119..829c36f5 100644 --- a/crates/hwp-render/src/layout.rs +++ b/crates/hwp-render/src/layout.rs @@ -901,26 +901,111 @@ pub(crate) struct BodyAnchor { pub starts_page: bool, } +/// Body anchors collected by the prepass, with an explicit completion result. +/// +/// A slot is `None` when the pass stopped before that paragraph was visited, so an +/// untouched entry can no longer be read as a legitimate page-top anchor (`v_pos == 0`, +/// `starts_page == false`). `completed` carries the other half of the answer: a budget stop +/// that refused a page *after* the last anchor was recorded leaves every slot filled, and a +/// floating-table continuation is exactly that case (#426). +#[derive(Clone, Debug, Default)] +pub(crate) struct BodyAnchors { + slots: Vec>>, + completed: bool, +} + +impl BodyAnchors { + pub(crate) fn completed(&self) -> bool { + self.completed + } + + pub(crate) fn slot(&self, section_index: usize, para_index: usize) -> Option { + self.slots + .get(section_index)? + .get(para_index) + .copied() + .flatten() + } + + /// Every paragraph of this section was visited with no budget stop, so its anchors may + /// drive the fragment flow. + pub(crate) fn section_complete(&self, section_index: usize) -> bool { + self.completed() + && self + .slots + .get(section_index) + .is_some_and(|slots| slots.iter().all(Option::is_some)) + } +} + /// Obtain body anchors from the same flow that plans floating-table fragments. /// The caller supplies cell caches and clears body caches before this measurement. +/// +/// The prepass gets its own accumulator, so it neither spends the caller's page/display-item +/// budget nor returns the document's warnings a second time - the real layout pass reports those +/// once. Only the diagnostics that can invalidate an anchor are propagated: a page budget that +/// refused a transition. The prepass sets no display-item limit itself, so that flag can only be +/// set by a caller that injects one later; it still counts as an incomplete pass because it is +/// the same refusal signal [`layout_document_bounded`] reads. pub(crate) fn synthesized_body_anchors( doc: &Document, store: &mut FontStore, -) -> Vec> { - let mut anchors: Vec> = doc + warnings: &mut RenderIssueAccumulator, +) -> BodyAnchors { + synthesized_body_anchors_with_limit(doc, store, warnings, None) +} + +/// [`synthesized_body_anchors`] with an explicit page budget, so a caller can prove how the +/// prepass behaves when it stops early. Production paths pass no limit. +pub(crate) fn synthesized_body_anchors_with_page_limit( + doc: &Document, + store: &mut FontStore, + warnings: &mut RenderIssueAccumulator, + page_limit: usize, +) -> BodyAnchors { + synthesized_body_anchors_with_limit(doc, store, warnings, Some(page_limit)) +} + +fn synthesized_body_anchors_with_limit( + doc: &Document, + store: &mut FontStore, + warnings: &mut RenderIssueAccumulator, + page_limit: Option, +) -> BodyAnchors { + let mut slots: Vec>> = doc .sections .iter() - .map(|section| vec![BodyAnchor::default(); section.paragraphs.len()]) + .map(|section| vec![None; section.paragraphs.len()]) .collect(); - let mut warnings = RenderIssueAccumulator::new(); + let mut prepass = RenderIssueAccumulator::new(); + if let Some(limit) = page_limit { + prepass.set_page_limit(limit); + } layout_document_inner( doc, store, - &mut warnings, + &mut prepass, &mut SegmentRecorder::disabled(), - Some(&mut anchors), + Some(&mut slots), ); - anchors + // Every early return inside the layout is a refused page transition (a body overflow, an + // explicit break, a paragraph's own line pagination, a table continuation or the section + // close), so these two flags cover all of them. The unvisited check covers a stop that + // returned before a later paragraph, and the flags cover a stop after the last one. + let stopped = prepass.page_budget_exceeded() || prepass.display_item_budget_exceeded(); + if prepass.page_budget_exceeded() { + warnings.push_once(RenderIssueCode::LayoutBudgetExceeded, b"pages"); + } + if prepass.display_item_budget_exceeded() { + warnings.push_once(RenderIssueCode::LayoutBudgetExceeded, b"display_items"); + } + let visited = slots + .iter() + .all(|section| section.iter().all(Option::is_some)); + BodyAnchors { + slots, + completed: visited && !stopped, + } } fn layout_document_inner( @@ -928,7 +1013,7 @@ fn layout_document_inner( store: &mut FontStore, warnings: &mut RenderIssueAccumulator, rec: &mut SegmentRecorder, - anchors: Option<&mut [Vec]>, + anchors: Option<&mut [Vec>]>, ) -> DisplayList { // Memoized cell heights are keyed by addresses inside `doc`; never let one // document's entries answer for another's, and do not keep them past the @@ -944,7 +1029,7 @@ fn layout_document_pages( store: &mut FontStore, warnings: &mut RenderIssueAccumulator, rec: &mut SegmentRecorder, - mut anchors: Option<&mut [Vec]>, + mut anchors: Option<&mut [Vec>]>, ) -> DisplayList { let mut pages = Vec::new(); let mut page_numbers = PageNumberState::new(doc.header.properties.start_numbers[0]); @@ -1202,10 +1287,10 @@ fn layout_document_pages( let mut bg_first_slice = true; if let Some(anchors) = anchors.as_deref_mut() { - anchors[section_index][para_index] = BodyAnchor { + anchors[section_index][para_index] = Some(BodyAnchor { v_pos: ((content_bottom + geom.spacing_top - body_top) * 100.0).round() as i32, starts_page: false, - }; + }); } if para.line_segs.is_empty() { // Cacheless body advance follows declared line spacing. Its fallback @@ -1365,11 +1450,11 @@ fn layout_document_pages( Some(&(_, first_baseline)), ) = (anchors.as_deref_mut(), plan.first(), lines.first()) { - anchors[section_index][para_index] = BodyAnchor { + anchors[section_index][para_index] = Some(BodyAnchor { v_pos: ((first_baseline + first_dy - max_size * 1.2 - body_top) * 100.0) .round() as i32, starts_page: first_opens_page.is_some(), - }; + }); } para_split = plan.iter().any(|&(_, _, opens)| opens.is_some()); let mut src = scratch.items.into_iter(); diff --git a/crates/hwp-render/src/lineseg.rs b/crates/hwp-render/src/lineseg.rs index 0f476ff9..a9592ac3 100644 --- a/crates/hwp-render/src/lineseg.rs +++ b/crates/hwp-render/src/lineseg.rs @@ -28,11 +28,38 @@ const TABLE_BLOCK_PADDING: i32 = 566; /// cacheless fragment anchors/page-start flags with restored genuine caches. The base path /// still restarts its cursor at an explicit page break, as genuine caches do. /// To match Hangul's line breaks, store must have HCR Batang loaded. +/// +/// A floating-table section reads its anchors from the prepass. If that pass did not finish +/// (a page or display-item budget stop, or a paragraph it never visited), the section is laid +/// out by the base cursor path instead and reports `anchor_prepass_incomplete`: default zero +/// anchors are never published as measured positions (#426). pub fn synthesize_linesegs( doc: &mut Document, store: &mut FontStore, warnings: &mut RenderIssueAccumulator, ) { + synthesize_linesegs_inner(doc, store, warnings, None); +} + +/// [`synthesize_linesegs`] with an explicit prepass page budget, so a test can prove how an +/// interrupted prepass is reported and where layout falls back to. Production passes no limit. +/// Returns the prepass result the section policies were decided from. +#[cfg(test)] +fn synthesize_linesegs_with_prepass_page_limit( + doc: &mut Document, + store: &mut FontStore, + warnings: &mut RenderIssueAccumulator, + page_limit: usize, +) -> Option { + synthesize_linesegs_inner(doc, store, warnings, Some(page_limit)) +} + +fn synthesize_linesegs_inner( + doc: &mut Document, + store: &mut FontStore, + warnings: &mut RenderIssueAccumulator, + prepass_page_limit: Option, +) -> Option { let snap = doc.clone(); let use_fragment_flow = doc.meta.source_format != "hwp5"; let floating_sections: Vec = doc @@ -72,12 +99,27 @@ pub fn synthesize_linesegs( para.line_segs.clear(); } } - Some(crate::layout::synthesized_body_anchors(&flow, store)) + Some(match prepass_page_limit { + Some(limit) => crate::layout::synthesized_body_anchors_with_page_limit( + &flow, store, warnings, limit, + ), + None => crate::layout::synthesized_body_anchors(&flow, store, warnings), + }) } else { None }; - for si in 0..doc.sections.len() { - let section_anchors = flow_anchors.as_ref().filter(|_| floating_sections[si]); + for (si, floating) in floating_sections.iter().copied().enumerate() { + // An incomplete prepass must not publish its default entries as measured anchors: + // that section keeps the base cursor path and reports the fallback once. + let section_anchors = flow_anchors + .as_ref() + .filter(|prepass| floating && prepass.section_complete(si)); + if floating && flow_anchors.is_some() && section_anchors.is_none() { + warnings.push( + crate::issues::RenderIssueCode::AnchorPrepassIncomplete, + format!("section={si}"), + ); + } let page = snap.sections[si] .section_def() .and_then(|d| d.page.as_ref()); @@ -145,15 +187,16 @@ pub fn synthesize_linesegs( } // A table anchor's cache starts at the incoming cursor after the preceding paragraph. // Genuine first-paragraph sample: body advance 1600, then table-anchor v_pos=1600. - if let Some(anchors) = section_anchors { - v_pos = anchors[si][pi].v_pos; + let anchor = section_anchors.and_then(|prepass| prepass.slot(si, pi)); + if let Some(anchor) = anchor { + v_pos = anchor.v_pos; } let anchor_v = v_pos; let src = &snap.sections[si].paragraphs[pi]; let mut segs = compute_linesegs( store, &snap, src, body_width, content_h, &mut v_pos, warnings, ); - if section_anchors.is_some_and(|anchors| anchors[si][pi].starts_page) + if anchor.is_some_and(|anchor| anchor.starts_page) && let Some(first) = segs.first_mut() { // HWP 5.0 §4.3.4 (PARA_LINE_SEG): bit0 marks the first line of a page. @@ -171,6 +214,7 @@ pub fn synthesize_linesegs( v_pos += sp_bottom; } } + flow_anchors } /// Synthesize every nested paragraph list: table captions/cells, controls within cells @@ -669,3 +713,282 @@ mod tests { assert_eq!(h, 141 + 141 + TABLE_BLOCK_PADDING); } } + +/// Regression coverage for #426: an anchor prepass that stops before it collected every +/// paragraph must not publish its unvisited slots as measured positions. +/// +/// Every fixture paginates from geometry that exists without any font: a body paragraph +/// advances 1600 HWPUNIT plus 300 of space-after, an empty cell paragraph is one 1000-HWPUNIT +/// line, and a line's baseline sits 1200 below its top with a 400 descent. The store is +/// isolated, so no host face is consulted and no assertion counts pages or reads glyph metrics. +#[cfg(test)] +mod anchor_prepass_tests { + use super::*; + use crate::issues::{RenderIssueAccumulator, RenderIssueCode}; + use hwp_model::{Cell, HwpUnit, ids::BorderFillId}; + + /// Body height that holds two paragraph lines (0 and 1900) and moves the third to page 2. + const TWO_LINE_BODY: i32 = 4000; + /// Body height that holds one paragraph line, so a three-row table cannot share the page. + const ONE_LINE_BODY: i32 = 2400; + + /// Zero-margin page of `height_hu` HWPUNIT: the body box is the whole page, so pagination + /// follows flow geometry alone. + fn set_body_height(doc: &mut Document, height_hu: i32) { + for control in &mut doc.sections[0].paragraphs[0].controls { + let Control::SectionDef(definition) = control else { + continue; + }; + let page = definition + .page + .as_mut() + .expect("from_markdown defines a page"); + page.height = HwpUnit(height_hu); + page.margin_top = HwpUnit(0); + page.margin_bottom = HwpUnit(0); + page.margin_header = HwpUnit(0); + page.margin_footer = HwpUnit(0); + page.margin_left = HwpUnit(0); + page.margin_right = HwpUnit(0); + } + } + + /// A body-flow table (`treat_as_char == false`), the shape that makes a section read + /// prepass anchors. Its cells hold one empty paragraph each. + fn floating_table(rows: u16, row_height: i32) -> Table { + let cells = (0..rows) + .map(|row| Cell { + list_attr: 0, + col: 0, + row, + col_span: 1, + row_span: 1, + width: HwpUnit(20000), + height: HwpUnit(row_height), + margins: [0; 4], + border_fill: BorderFillId(1), + header_tail: Vec::new(), + paragraphs: vec![Paragraph::default()], + }) + .collect(); + Table { + common_data: Vec::new(), + placement: Some(hwp_model::GsoPlacement { + treat_as_char: false, + vert_rel_to: 2, + horz_rel_to: 3, + ..Default::default() + }), + attr: 1, + rows, + cols: 1, + cell_spacing: 0, + inner_margins: [0; 4], + row_cell_counts: vec![1; usize::from(rows)], + border_fill: BorderFillId(1), + table_tail: Vec::new(), + cells, + caption: None, + extras: Vec::new(), + } + } + + fn issue_count(report: &crate::issues::RenderIssueReport, code: RenderIssueCode) -> u64 { + report + .issues + .iter() + .filter(|issue| issue.code == code) + .map(|issue| issue.count) + .sum() + } + + fn synthesize_complete( + doc: &mut Document, + ) -> (crate::layout::BodyAnchors, crate::issues::RenderIssueReport) { + let mut store = FontStore::new_isolated(); + let mut warnings = RenderIssueAccumulator::new(); + let anchors = synthesize_linesegs_inner(doc, &mut store, &mut warnings, None) + .expect("a floating-table section runs the prepass"); + (anchors, warnings.finish()) + } + + fn synthesize_with_prepass_limit( + doc: &mut Document, + page_limit: usize, + ) -> (crate::layout::BodyAnchors, crate::issues::RenderIssueReport) { + let mut store = FontStore::new_isolated(); + let mut warnings = RenderIssueAccumulator::new(); + let anchors = + synthesize_linesegs_with_prepass_page_limit(doc, &mut store, &mut warnings, page_limit) + .expect("a floating-table section runs the prepass"); + (anchors, warnings.finish()) + } + + /// Ordinary complete pass: measured anchors drive the section, including the page-top anchor + /// whose `starts_page` still has to reach the synthesized first line. + #[test] + fn complete_prepass_publishes_every_measured_anchor_and_its_page_start() { + let mut doc = hwp_convert::from_markdown("A\n\nB\n\nC\n"); + set_body_height(&mut doc, TWO_LINE_BODY); + doc.sections[0].paragraphs[0] + .controls + .push(Control::Table(floating_table(1, 1000))); + + let (anchors, warnings) = synthesize_complete(&mut doc); + assert!(anchors.completed(), "an unconstrained prepass completes"); + assert!(anchors.section_complete(0)); + assert_eq!( + issue_count(&warnings, RenderIssueCode::AnchorPrepassIncomplete), + 0, + "a complete pass reports no fallback" + ); + + let page_top = anchors.slot(0, 2).expect("third paragraph visited"); + assert!( + page_top.starts_page, + "fixture: the third paragraph's first line opens page 2" + ); + for pi in 0..3 { + let anchor = anchors + .slot(0, pi) + .unwrap_or_else(|| panic!("paragraph {pi} visited")); + let first = &doc.sections[0].paragraphs[pi].line_segs[0]; + assert_eq!( + first.v_pos, anchor.v_pos, + "paragraph {pi} publishes its measured anchor, not a base-cursor position" + ); + assert_eq!( + first.flags & 0x1 != 0, + anchor.starts_page, + "paragraph {pi} keeps the measured page-start bit" + ); + } + } + + /// Stop inside a paragraph's own line pagination: the third paragraph's first line opens + /// page 2, which is the document's first page transition, so the fourth paragraph is never + /// visited and its slot stays empty. + #[test] + fn prepass_stop_during_paragraph_pagination_never_publishes_a_default_zero_anchor() { + let mut doc = hwp_convert::from_markdown("A\n\nB\n\nC\n\nD\n"); + set_body_height(&mut doc, TWO_LINE_BODY); + doc.sections[0].paragraphs[3] + .controls + .push(Control::Table(floating_table(1, 500))); + + let (anchors, warnings) = synthesize_with_prepass_limit(&mut doc, 0); + let opening = anchors.slot(0, 2).expect("third paragraph visited"); + assert!( + opening.starts_page, + "fixture: the stop happens where the third paragraph's first line opens page 2" + ); + assert!( + anchors.slot(0, 3).is_none(), + "the fourth paragraph was never visited" + ); + assert!( + !anchors.completed(), + "a page-budget stop is not a complete pass" + ); + assert!(!anchors.section_complete(0)); + assert_eq!( + issue_count(&warnings, RenderIssueCode::AnchorPrepassIncomplete), + 1, + "the section reports its fallback once, not once per paragraph" + ); + assert_eq!( + issue_count(&warnings, RenderIssueCode::LayoutBudgetExceeded), + 1, + "the prepass's own budget diagnostic reaches the caller" + ); + + let first = &doc.sections[0].paragraphs[3].line_segs[0]; + assert_ne!( + first.v_pos, 0, + "an unvisited anchor must not be published as a default-zero position" + ); + } + + /// Stop inside the floating table's own page transition, after every paragraph's anchor was + /// already recorded: the slots cannot reveal this stop, so the completion result has to. + #[test] + fn prepass_stop_during_floating_table_continuation_still_reports_incomplete() { + let mut doc = hwp_convert::from_markdown("A\n\nB\n"); + set_body_height(&mut doc, ONE_LINE_BODY); + doc.sections[0].paragraphs[0] + .controls + .push(Control::Table(floating_table(3, 1000))); + + let (anchors, warnings) = synthesize_with_prepass_limit(&mut doc, 0); + assert!( + anchors.slot(0, 0).is_some() && anchors.slot(0, 1).is_some(), + "every paragraph's anchor was recorded before the table needed its next page" + ); + assert!( + !anchors.completed(), + "a stop after the last anchor still leaves the prepass incomplete" + ); + assert!(!anchors.section_complete(0)); + assert_eq!( + issue_count(&warnings, RenderIssueCode::AnchorPrepassIncomplete), + 1 + ); + assert_eq!( + issue_count(&warnings, RenderIssueCode::LayoutBudgetExceeded), + 1 + ); + + let measured = anchors.slot(0, 1).expect("second paragraph visited"); + assert!( + measured.starts_page, + "fixture: the second paragraph opens a page once the table continues" + ); + let first = &doc.sections[0].paragraphs[1].line_segs[0]; + assert_eq!( + first.flags & 0x1, + 0, + "the base cursor fallback must not apply a page-start flag it did not measure" + ); + } + + /// Stop inside the first section leaves a later floating-table section unvisited; it must be + /// reported and laid out by the base cursor, not from empty slots. + #[test] + fn prepass_stop_leaves_a_later_section_unvisited_and_reported() { + let mut doc = hwp_convert::from_markdown("A\n\nB\n\nC\n\nD\n"); + set_body_height(&mut doc, TWO_LINE_BODY); + let mut later = hwp_convert::from_markdown("E\n\nF\n"); + set_body_height(&mut later, TWO_LINE_BODY); + later.sections[0].paragraphs[1] + .controls + .push(Control::Table(floating_table(1, 500))); + doc.sections.push(later.sections.remove(0)); + + let (anchors, warnings) = synthesize_with_prepass_limit(&mut doc, 0); + assert!( + anchors.slot(0, 2).is_some(), + "fixture: the stop happens inside section 0's paragraph pagination" + ); + assert!( + anchors.slot(1, 0).is_none() && anchors.slot(1, 1).is_none(), + "the later section is never visited" + ); + assert!(!anchors.completed()); + assert!(!anchors.section_complete(1)); + assert_eq!( + issue_count(&warnings, RenderIssueCode::AnchorPrepassIncomplete), + 1, + "only the floating-table section consumes anchors and reports" + ); + + assert!( + !doc.sections[1].paragraphs[0].line_segs.is_empty(), + "the fallback still synthesizes the unvisited section" + ); + let first = &doc.sections[1].paragraphs[1].line_segs[0]; + assert_ne!( + first.v_pos, 0, + "an unvisited later section is not published as default-zero anchors" + ); + } +} diff --git a/docs/design/05-rendering.ko.md b/docs/design/05-rendering.ko.md index 9c07bb3a..7e0d3b49 100644 --- a/docs/design/05-rendering.ko.md +++ b/docs/design/05-rendering.ko.md @@ -147,6 +147,14 @@ v_pos = 0 // 페이지 상대 누적 ``` 문단마다 `spacing_top`(첫 문단 제외)을 v_pos에 더하고, `fill_nested`로 셀/글상자 내부 줄배치를 먼저 채운 뒤, 표 총높이 계산. **표가 잔여 공간에 안 들어가면**(`v_pos + table_total > content_h`) `v_pos=0`으로 다음 페이지. 표 앵커 문단은 진입 v_pos에 놓고, 표 있는 문단은 `v_pos = anchor_v + table_total`로 커서를 덮어쓴다(겹침 방지). 마지막에 `spacing_bottom` 가산. +### 2.2.1 본문 흐름 표가 있는 섹션의 사전 앵커 + +본문 흐름 표(`placement.treat_as_char == false`)를 가진 섹션은 위 커서를 쓸 수 없다. 렌더러가 그 표의 행/셀 조각을 나눠 배치하므로 문단 위치도 같은 레이아웃 패스에서 나와야 한다. `layout::synthesized_body_anchors`가 본문 캐시를 모두 비운 복사본에 레이아웃을 한 번 돌려 문단마다 확정된 첫 줄 상단(`BodyAnchor::v_pos`)과 그 줄이 페이지를 열었는지(`starts_page`)를 기록한다. `synthesize_linesegs`는 그런 섹션의 각 문단을 앵커에서 시작하고, 앵커가 페이지를 연 경우 `LineSeg.flags` bit0을 세운다(한글문서파일형식 5.0 §4.3.4). HWP5 원본은 기존 커서 경로를 그대로 쓰므로 정품 캐시와 HWP5 동일성은 건드리지 않는다. + +사전 패스 결과는 문단마다 `Option` 슬롯을 갖는 `BodyAnchors`와 `completed` 플래그다(#426). 슬롯이 `None`이면 그 문단을 방문하기 전에 패스가 멈춘 것이므로, 채워지지 않은 항목을 정상적인 페이지 상단 앵커(`v_pos = 0`, `starts_page = false`)로 읽을 수 없다. `completed`는 페이지나 표시 항목 예산이 페이지 전환을 거부하면 거짓이 된다. 모든 앵커를 이미 기록한 뒤에 멈춘 경우도 포함하며, 다음 페이지가 필요한 본문 흐름 표가 정확히 그 경우다. + +정책: 미완 사전 패스는 앵커를 게시하지 않는다. 앵커가 완전하지 않은 본문 흐름 표 섹션은 위 기본 커서 경로로 되돌아가고 섹션마다 `anchor_prepass_incomplete`(`section=`, 심각도 `incomplete`, 단계 `layout`)를 한 번 보고하며, 사전 패스 자체의 예산 거부는 `layout_budget_exceeded`로 전달된다. 거부(typed refusal) 대신 폴백을 택한 이유는 기본 커서가 그 섹션들의 기존 배치이기 때문이다. 가정상의 중단은 렌더 실패가 아니라 위치 열화로 끝난다. 사전 패스는 전용 누산기를 쓰므로 호출자의 페이지/표시 항목 예산을 소비하지 않고 문서의 다른 경고를 두 번 보고하지 않는다. 전달하는 것은 앵커를 무효화하는 진단뿐이다. + ### 2.3 문단 한 개 줄배치 (`compute_linesegs`) 줄 메트릭을 첫 글자모양 `base_size`(기본 1000)에서 유도: diff --git a/docs/design/05-rendering.md b/docs/design/05-rendering.md index 025a1009..a16375a2 100644 --- a/docs/design/05-rendering.md +++ b/docs/design/05-rendering.md @@ -314,6 +314,33 @@ next page. The paragraph anchoring a table sits at its entry v_pos, and a paragr overwrites the cursor with `v_pos = anchor_v + table_total` (preventing overlap). Finally `spacing_bottom` is added. +### 2.2.1 Floating-table sections read a prepass anchor set + +A section whose paragraphs carry a body-flow table (`placement.treat_as_char == false`) cannot use +the cursor above: the renderer plans legal row/cell fragments for that table, so the paragraph +positions must come from the same layout pass. `layout::synthesized_body_anchors` runs the layout +once over a copy with every body cache cleared and records, per paragraph, the resolved first-line +top (`BodyAnchor::v_pos`) and whether that first line opened a page (`starts_page`). +`synthesize_linesegs` then starts each paragraph of such a section at its anchor and sets +`LineSeg.flags` bit 0 where the anchor opened a page (HWP 5.0 §4.3.4). Native HWP5 sources keep the +base cursor, so genuine caches and HWP5 identity are untouched. + +The prepass returns a `BodyAnchors`: one `Option` slot per paragraph plus a `completed` +flag (#426). A slot is `None` when the pass stopped before that paragraph was visited, so an +untouched entry can no longer be read as a legitimate page-top anchor (`v_pos = 0`, +`starts_page = false`). `completed` is false when a page or display-item budget refused a page +transition anywhere in the pass, even after every anchor was recorded - a floating table that needs a +continuation page is exactly that case. + +Policy: an incomplete prepass never publishes its anchors. A floating-table section whose anchors are +not complete falls back to the base cursor above and reports `anchor_prepass_incomplete` +(`section=`; severity `incomplete`, stage `layout`) once per section, while the prepass's own +budget refusal is propagated as `layout_budget_exceeded`. Falling back rather than refusing is +deliberate: the base cursor is the pre-existing layout for those sections, so a hypothetical stop +degrades positions instead of failing a render. The prepass keeps a private accumulator, so it +neither spends the caller's page/display-item budget nor repeats the document's other warnings; only +the diagnostics that can invalidate an anchor are propagated. + ### 2.3 Line layout for one paragraph (`compute_linesegs`) Line metrics are derived from the `base_size` of the first character shape (1000 by default): diff --git a/schemas/certification-report-v1.schema.json b/schemas/certification-report-v1.schema.json index 84e8a752..d3741501 100644 --- a/schemas/certification-report-v1.schema.json +++ b/schemas/certification-report-v1.schema.json @@ -460,7 +460,7 @@ "type": "object", "additionalProperties": false, "required": ["code", "severity", "stage", "count", "sample_sha256", "samples_complete"], "properties": { - "code": { "enum": ["parse_budget_exceeded", "render_execution_failed", "pagination_drift_detected", "font_matched", "font_substituted", "font_missing", "font_manifest_load_failed", "font_resolution_budget_exceeded", "shaping_failed", "page_definition_fallback", "page_control_payload_omitted", "page_number_format_fallback", "page_number_position_omitted", "page_number_shaping_omitted", "unsupported_control_omitted", "image_size_missing_omitted", "image_data_missing_omitted", "image_decode_placeholder", "image_decode_budget_exceeded", "picture_effects_unsupported", "invalid_table_cell_omitted", "table_split_across_pages", "table_cell_fragmentation_incomplete", "table_row_too_tall_clipped", "table_cell_content_overflow", "paragraph_line_content_overflow", "text_box_geometry_invalid_omitted", "shape_depth_limit_omitted", "shape_style_invalid_omitted", "shape_geometry_invalid_omitted", "font_subset_fallback", "layout_budget_exceeded", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted", "wmf_budget_exceeded"] }, + "code": { "enum": ["parse_budget_exceeded", "render_execution_failed", "pagination_drift_detected", "font_matched", "font_substituted", "font_missing", "font_manifest_load_failed", "font_resolution_budget_exceeded", "shaping_failed", "page_definition_fallback", "page_control_payload_omitted", "page_number_format_fallback", "page_number_position_omitted", "page_number_shaping_omitted", "unsupported_control_omitted", "image_size_missing_omitted", "image_data_missing_omitted", "image_decode_placeholder", "image_decode_budget_exceeded", "picture_effects_unsupported", "invalid_table_cell_omitted", "table_split_across_pages", "table_cell_fragmentation_incomplete", "table_row_too_tall_clipped", "table_cell_content_overflow", "paragraph_line_content_overflow", "text_box_geometry_invalid_omitted", "shape_depth_limit_omitted", "shape_style_invalid_omitted", "shape_geometry_invalid_omitted", "font_subset_fallback", "layout_budget_exceeded", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted", "wmf_budget_exceeded", "anchor_prepass_incomplete"] }, "severity": { "enum": ["info", "warning", "incomplete", "fatal"] }, "stage": { "enum": ["input_parse", "font_resolution", "layout", "shaping", "rasterization", "pdf_export"] }, "count": { "type": "integer", "minimum": 1, "maximum": 1000000 }, @@ -477,7 +477,7 @@ { "properties": { "code": { "const": "font_manifest_load_failed" }, "severity": { "const": "fatal" }, "stage": { "const": "font_resolution" } } }, { "properties": { "code": { "const": "font_resolution_budget_exceeded" }, "severity": { "const": "fatal" }, "stage": { "const": "font_resolution" } } }, { "properties": { "code": { "enum": ["shaping_failed", "page_number_shaping_omitted"] }, "severity": { "const": "incomplete" }, "stage": { "const": "shaping" } } }, - { "properties": { "code": { "enum": ["page_definition_fallback", "page_control_payload_omitted", "page_number_format_fallback", "page_number_position_omitted", "unsupported_control_omitted", "image_size_missing_omitted", "image_data_missing_omitted", "invalid_table_cell_omitted", "table_cell_fragmentation_incomplete", "table_row_too_tall_clipped", "text_box_geometry_invalid_omitted", "shape_depth_limit_omitted", "shape_style_invalid_omitted", "shape_geometry_invalid_omitted", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted"] }, "severity": { "const": "incomplete" }, "stage": { "const": "layout" } } }, + { "properties": { "code": { "enum": ["page_definition_fallback", "page_control_payload_omitted", "page_number_format_fallback", "page_number_position_omitted", "unsupported_control_omitted", "image_size_missing_omitted", "image_data_missing_omitted", "invalid_table_cell_omitted", "table_cell_fragmentation_incomplete", "table_row_too_tall_clipped", "anchor_prepass_incomplete", "text_box_geometry_invalid_omitted", "shape_depth_limit_omitted", "shape_style_invalid_omitted", "shape_geometry_invalid_omitted", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted"] }, "severity": { "const": "incomplete" }, "stage": { "const": "layout" } } }, { "properties": { "code": { "const": "image_decode_placeholder" }, "severity": { "const": "incomplete" }, "stage": { "const": "rasterization" } } }, { "properties": { "code": { "const": "picture_effects_unsupported" }, "severity": { "const": "warning" }, "stage": { "const": "layout" } } }, { "properties": { "code": { "enum": ["table_cell_content_overflow", "paragraph_line_content_overflow"] }, "severity": { "const": "warning" }, "stage": { "const": "layout" } } }, diff --git a/schemas/render-report-v1.schema.json b/schemas/render-report-v1.schema.json index 735803d1..683902af 100644 --- a/schemas/render-report-v1.schema.json +++ b/schemas/render-report-v1.schema.json @@ -155,7 +155,8 @@ "layout_budget_exceeded", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted", - "wmf_budget_exceeded" + "wmf_budget_exceeded", + "anchor_prepass_incomplete" ] }, "severity": { "enum": ["info", "warning", "incomplete", "fatal"] }, @@ -180,7 +181,7 @@ { "properties": { "code": { "const": "font_manifest_load_failed" }, "severity": { "const": "fatal" }, "stage": { "const": "font_resolution" } } }, { "properties": { "code": { "const": "font_resolution_budget_exceeded" }, "severity": { "const": "fatal" }, "stage": { "const": "font_resolution" } } }, { "properties": { "code": { "enum": ["shaping_failed", "page_number_shaping_omitted"] }, "severity": { "const": "incomplete" }, "stage": { "const": "shaping" } } }, - { "properties": { "code": { "enum": ["page_definition_fallback", "page_control_payload_omitted", "page_number_format_fallback", "page_number_position_omitted", "unsupported_control_omitted", "image_size_missing_omitted", "image_data_missing_omitted", "invalid_table_cell_omitted", "table_cell_fragmentation_incomplete", "table_row_too_tall_clipped", "text_box_geometry_invalid_omitted", "shape_depth_limit_omitted", "shape_style_invalid_omitted", "shape_geometry_invalid_omitted", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted"] }, "severity": { "const": "incomplete" }, "stage": { "const": "layout" } } }, + { "properties": { "code": { "enum": ["page_definition_fallback", "page_control_payload_omitted", "page_number_format_fallback", "page_number_position_omitted", "unsupported_control_omitted", "image_size_missing_omitted", "image_data_missing_omitted", "invalid_table_cell_omitted", "table_cell_fragmentation_incomplete", "table_row_too_tall_clipped", "anchor_prepass_incomplete", "text_box_geometry_invalid_omitted", "shape_depth_limit_omitted", "shape_style_invalid_omitted", "shape_geometry_invalid_omitted", "wmf_parse_invalid_placeholder", "wmf_unsupported_record_omitted"] }, "severity": { "const": "incomplete" }, "stage": { "const": "layout" } } }, { "properties": { "code": { "const": "image_decode_placeholder" }, "severity": { "const": "incomplete" }, "stage": { "const": "rasterization" } } }, { "properties": { "code": { "const": "picture_effects_unsupported" }, "severity": { "const": "warning" }, "stage": { "const": "layout" } } }, { "properties": { "code": { "enum": ["table_cell_content_overflow", "paragraph_line_content_overflow"] }, "severity": { "const": "warning" }, "stage": { "const": "layout" } } }, From 6eb591406be55567cde7dc64283df50981f25be7 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 23:02:33 +0900 Subject: [PATCH 2/2] chore(render): translate the remaining Korean comments in issues.rs The file is touched by #426, so AGENTS.md requires its development comments in English. This is comment-only: the module doc, the three WMF code docs and the two accumulator method docs, with the same meaning. User-facing Korean strings in this crate are unchanged. --- crates/hwp-render/src/issues.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/hwp-render/src/issues.rs b/crates/hwp-render/src/issues.rs index 13398538..c28a5649 100644 --- a/crates/hwp-render/src/issues.rs +++ b/crates/hwp-render/src/issues.rs @@ -1,7 +1,7 @@ -//! 렌더 진단의 고정 코드, 심각도, 단계 및 source-bounded 집계기. +//! Fixed codes, severities, stages and the source-bounded aggregator for render diagnostics. //! -//! 문서 텍스트나 경로를 보관하지 않는다. 호출자가 제공한 detail은 즉시 SHA-256으로 -//! 바뀌며, 코드별로 제한된 수의 sample hash만 남는다. +//! Neither document text nor paths are stored: a caller-provided detail becomes its SHA-256 at +//! once, and only a bounded number of sample hashes remain per code. use std::collections::BTreeMap; use std::fmt; @@ -100,11 +100,13 @@ pub enum RenderIssueCode { ShapeGeometryInvalidOmitted, FontSubsetFallback, LayoutBudgetExceeded, - /// WMF 스트림이 손상/절단되어 해석을 중단하고 자홍색 placeholder로 대체. + /// The WMF stream was corrupt or truncated, so parsing stopped and a magenta placeholder + /// took its place. WmfParseInvalidPlaceholder, - /// WMF 부분집합 밖 레코드 계열을 bounded-skip (detail은 함수 코드뿐). + /// A record family outside the supported WMF subset was skipped in a bounded way (the detail + /// is the function code only). WmfUnsupportedRecordOmitted, - /// WMF 해석의 레코드/객체/픽셀 상한 초과 → placeholder. + /// A record, object or pixel ceiling of WMF parsing was exceeded -> placeholder. WmfBudgetExceeded, /// The body-anchor prepass for a floating-table section stopped before it collected every /// paragraph's anchor (a page or display-item budget stop, or an unvisited paragraph). The @@ -398,8 +400,8 @@ impl RenderIssueAccumulator { } } - /// 같은 문서 바이너리 slice는 렌더 전체에서 한 번만 복사하고 모든 이미지 Item이 - /// 같은 Arc를 공유한다. 인증 경로의 byte quota는 layout preflight가 적용한다. + /// One document binary slice is copied once per render and every image item shares that Arc. + /// The certification path's byte quota is applied by the layout preflight. pub fn cached_binary(&mut self, bytes: &[u8]) -> Arc> { let identity = (bytes.as_ptr() as usize, bytes.len()); self.binary_cache @@ -412,8 +414,8 @@ impl RenderIssueAccumulator { self.display_item_limit = Some(limit); } - /// Display Item을 Vec에 넣기 전에 호출한다. false면 caller는 push/insert를 하지 - /// 않아야 하며 bounded layout은 즉시 실패 상태로 전환된다. + /// Call before putting a display item into a Vec. On false the caller must not push or + /// insert, and the bounded layout switches to its failed state at once. pub fn charge_display_items(&mut self, count: usize) -> bool { let Some(next) = self.display_item_count.checked_add(count) else { self.display_item_budget_exceeded = true;