Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`&lt;`, `&gt;`, `&amp;`) inside `paraHead` text instead of
dropping them, so `hwp compose` writes an ordered-list marker such as `<{n}>` escaped and
Expand Down
34 changes: 22 additions & 12 deletions crates/hwp-render/src/issues.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Comment thread
entelecheia marked this conversation as resolved.
Resolved
}

impl RenderIssueCode {
Expand All @@ -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,
Expand Down Expand Up @@ -150,6 +157,7 @@ impl RenderIssueCode {
Self::WmfParseInvalidPlaceholder,
Self::WmfUnsupportedRecordOmitted,
Self::WmfBudgetExceeded,
Self::AnchorPrepassIncomplete,
];

pub const fn as_str(self) -> &'static str {
Expand Down Expand Up @@ -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",
}
}

Expand Down Expand Up @@ -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<Vec<u8>> {
let identity = (bytes.as_ptr() as usize, bytes.len());
self.binary_cache
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
111 changes: 98 additions & 13 deletions crates/hwp-render/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,34 +901,119 @@ 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<Vec<Option<BodyAnchor>>>,
completed: bool,
}

impl BodyAnchors {
pub(crate) fn completed(&self) -> bool {
self.completed
}

pub(crate) fn slot(&self, section_index: usize, para_index: usize) -> Option<BodyAnchor> {
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<Vec<BodyAnchor>> {
let mut anchors: Vec<Vec<BodyAnchor>> = 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<usize>,
) -> BodyAnchors {
let mut slots: Vec<Vec<Option<BodyAnchor>>> = 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(
doc: &Document,
store: &mut FontStore,
warnings: &mut RenderIssueAccumulator,
rec: &mut SegmentRecorder,
anchors: Option<&mut [Vec<BodyAnchor>]>,
anchors: Option<&mut [Vec<Option<BodyAnchor>>]>,
) -> 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
Expand All @@ -944,7 +1029,7 @@ fn layout_document_pages(
store: &mut FontStore,
warnings: &mut RenderIssueAccumulator,
rec: &mut SegmentRecorder,
mut anchors: Option<&mut [Vec<BodyAnchor>]>,
mut anchors: Option<&mut [Vec<Option<BodyAnchor>>]>,
) -> DisplayList {
let mut pages = Vec::new();
let mut page_numbers = PageNumberState::new(doc.header.properties.start_numbers[0]);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading