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..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,12 +100,19 @@ 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 + /// section falls back to the measured base cursor rather than publishing default zero + /// anchors as measured positions (#426). + AnchorPrepassIncomplete, } impl RenderIssueCode { @@ -114,7 +121,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 +157,7 @@ impl RenderIssueCode { Self::WmfParseInvalidPlaceholder, Self::WmfUnsupportedRecordOmitted, Self::WmfBudgetExceeded, + Self::AnchorPrepassIncomplete, ]; pub const fn as_str(self) -> &'static str { @@ -189,6 +197,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", } } @@ -391,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 @@ -405,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; @@ -615,7 +624,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" } } },