Skip to content
Merged
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,16 @@ semantic payload schema is versioned separately with `schema_version`.
- Recorded a loss entry when outline heading levels deeper than 6 are clamped.
- Fixed font-info loss reporting to inspect all seven language slots instead of
only the Korean (default) slot.
- Documented deferred conversion gaps in `docs/v2-known-limitations.md`.
- Added inline hyperlink support: hyperlink fields are emitted as `Inline::Href`
(`<href uri="…">…</href>` in XML, `[anchor](uri)` in Markdown, `kind: "href"`
with a `uri` field in the semantic payload), resolved from the anchor span via
the paragraph's `field_ranges`.
- Expanded EqEdit→LaTeX coverage with more no-argument symbols (perp, parallel,
langle/rangle, setminus, long/hook arrows, …) and over/under decorations
(overline, underline, widehat, ddot, …).
- Interleaved in-text flow objects (tables, pictures, formulas) at their true
character position within ordinary paragraphs instead of appending them.
- Reported layout-pass failures through `LossReport` (`location-unavailable`,
`location-page-layout-failed`) instead of returning a silent empty map.
- Recorded table-cell collisions (`table-cell-collision`) when clamped anchors
overlap, and capped the OTSL grid size to avoid OOM on malformed huge tables.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ payload에서 bbox가 없을 때는 `not_requested`, `no_provenance`, `unresolve
| 개요 제목 | `<heading level="1–6">` | 글꼴 크기 추측이 아닌 개요 수준 기준 |
| 목록(번호/글머리표) | `<list>` / `<ldiv>` / `<marker>` | 중첩 지원 |
| 인라인 서식 | `<bold>` `<italic>` `<underline>` `<strikethrough>` `<superscript>` `<subscript>` | |
| 하이퍼링크 | `<href uri="…">` | 앵커 구간을 `field_ranges`로 식별; 웹/mailto/내부 책갈피 |
| 표 | OTSL (`<fcel>` `<ched>` `<lcel>` `<ucel>` `<xcel>` …) | 병합 · 중첩 셀 |
| 수식 | `<formula>` (LaTeX) | EqEdit → LaTeX; 변환 누락 토큰은 보고됨 |
| 이미지 | `<picture><src uri="…"/>` | 기본값은 인라인 base64; asset/URI 참조 정책 지원 |
Expand Down Expand Up @@ -241,11 +242,10 @@ payload에서 bbox가 없을 때는 `not_requested`, `no_provenance`, `unresolve
않다는 것은, 예를 들어 인제스천 파이프라인이 해당 파일을 조용히 누락시키는 대신
더 풍부한(예: VLM) 경로로 분기시킬 수 있다는 의미입니다.

DocLang 대응 요소가 없는 일부 컨트롤(HWP3 하이퍼링크 표시 텍스트, 덧말(Ruby),
글자겹침(CharOverlap))은 텍스트를 버리지 않고 평문 블록으로 **구제**하며, 잃은
의미(링크/덧말/겹침)는 `LossEntry`로 기록합니다. 의도적으로 v2로 미룬 변환 한계
(인라인 `<href>`, 인텍스트 객체 위치, 미주 별도 표현 등)는
[`docs/v2-known-limitations.md`](docs/v2-known-limitations.md)에 정리되어 있습니다.
DocLang 대응 요소가 없는 일부 컨트롤(덧말(Ruby), 글자겹침(CharOverlap))은 텍스트를
버리지 않고 평문 블록으로 **구제**하며, 잃은 의미는 `LossEntry`로 기록합니다.
하이퍼링크와 인텍스트 흐름 객체 위치는 v2에서 구현되었습니다. 남은 변환 한계로는
미주 별도 표현, 좌표 미부착 영역(글상자/각주/셀 내부) 등이 있습니다.

---

Expand Down
56 changes: 0 additions & 56 deletions docs/v2-known-limitations.md

This file was deleted.

67 changes: 67 additions & 0 deletions src/eqedit/latex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,47 @@ fn command_for_exact(word: &str) -> Option<&'static str> {
"ldots" => "ldots",
"vdots" => "vdots",
"ddots" => "ddots",
// Delimiter symbols (used bare, not via left/right)
"langle" => "langle",
"rangle" => "rangle",
"lfloor" => "lfloor",
"rfloor" => "rfloor",
"lceil" => "lceil",
"rceil" => "rceil",
// More relations / logic
"perp" => "perp",
"parallel" => "parallel",
"mid" => "mid",
"vdash" => "vdash",
"models" => "models",
"asymp" => "asymp",
"doteq" => "doteq",
"prec" => "prec",
"succ" => "succ",
"preceq" => "preceq",
"succeq" => "succeq",
"top" => "top",
"bot" => "bot",
// More operators / symbols
"setminus" => "setminus",
"triangle" => "triangle",
"diamond" => "diamond",
"surd" => "surd",
"wp" => "wp",
"sharp" => "sharp",
"flat" => "flat",
"natural" => "natural",
// More arrows
"longrightarrow" => "longrightarrow",
"longleftarrow" => "longleftarrow",
"Longrightarrow" => "Longrightarrow",
"Longleftarrow" => "Longleftarrow",
"hookrightarrow" => "hookrightarrow",
"hookleftarrow" => "hookleftarrow",
"nearrow" => "nearrow",
"searrow" => "searrow",
"swarrow" => "swarrow",
"nwarrow" => "nwarrow",
// Misc
"prime" => "prime",
"angle" => "angle",
Expand Down Expand Up @@ -533,4 +574,30 @@ mod tests {
fn emit_binom() {
assert_eq!(conv("binom {n} {k}"), "\\binom{n}{k}");
}

#[test]
fn emit_added_noarg_symbols() {
// Previously unrecognised symbols that fell through to \text{…}.
assert_eq!(conv("perp"), "\\perp");
assert_eq!(conv("parallel"), "\\parallel");
assert_eq!(conv("langle"), "\\langle");
assert_eq!(conv("setminus"), "\\setminus");
assert_eq!(conv("longrightarrow"), "\\longrightarrow");
// Case-insensitive lookup still applies.
assert_eq!(conv("PERP"), "\\perp");
}

#[test]
fn emit_added_decorations() {
assert_eq!(conv("overline x"), "\\overline{x}");
assert_eq!(conv("underline {x+1}"), "\\underline{x+1}");
assert_eq!(conv("widehat A"), "\\widehat{A}");
assert_eq!(conv("ddot x"), "\\ddot{x}");
}

#[test]
fn unknown_word_still_passes_through_as_text() {
// A genuinely unknown command-like word must still degrade, not resolve.
assert_eq!(conv("notacommand"), "\\text{notacommand}");
}
}
17 changes: 15 additions & 2 deletions src/eqedit/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,10 @@ impl<'a> Parser<'a> {
"bmatrix" => self.parse_matrix("bmatrix"),
"dmatrix" => self.parse_matrix("vmatrix"),
"cases" => self.parse_matrix("cases"),
// Decorations: accent applies to the following atom.
"bar" | "hat" | "vec" | "dot" | "tilde" | "acute" | "grave" => {
// Decorations: accent / over-under command applies to the following atom.
"bar" | "hat" | "vec" | "dot" | "ddot" | "tilde" | "acute" | "grave" | "check"
| "breve" | "mathring" | "widehat" | "widetilde" | "overline" | "underline"
| "overbrace" | "underbrace" | "overrightarrow" => {
let accent = decoration_command(&kw);
let target = self.parse_atom()?;
Ok(Node::Decoration(accent.to_string(), Box::new(target)))
Expand Down Expand Up @@ -464,9 +466,20 @@ fn decoration_command(word: &str) -> &'static str {
"hat" => "hat",
"vec" => "vec",
"dot" => "dot",
"ddot" => "ddot",
"tilde" => "tilde",
"acute" => "acute",
"grave" => "grave",
"check" => "check",
"breve" => "breve",
"mathring" => "mathring",
"widehat" => "widehat",
"widetilde" => "widetilde",
"overline" => "overline",
"underline" => "underline",
"overbrace" => "overbrace",
"underbrace" => "underbrace",
"overrightarrow" => "overrightarrow",
_ => "bar",
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/ir/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ pub enum Inline {
LineBreak,
/// Tab character.
Tab,
/// A hyperlink span: `content` is the anchor inline content and `uri` the
/// link target. Maps to DocLang `<href uri="…">…</href>`. Produced from a
/// HWP hyperlink field whose `field_ranges` cover the anchor text.
Href { uri: String, content: Vec<Inline> },
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ pub fn extract_semantic(
// disabled the map stays empty and the writer emits no <location> elements,
// producing byte-identical output to a location-free build.
let locs = if opts.with_location {
parser_adapter::build_location_map(data, &document)
parser_adapter::build_location_map(data, &document, &mut loss)
} else {
ir::prov::LocationMap::new()
};
Expand Down
8 changes: 8 additions & 0 deletions src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@ fn write_inline(out: &mut String, inline: &Inline) {
Inline::FootnoteRef(number) => out.push_str(&format!("[^{number}]")),
Inline::LineBreak => out.push('\n'),
Inline::Tab => out.push(' '),
Inline::Href { uri, content } => {
// Markdown inline link: [anchor](uri).
out.push('[');
out.push_str(&inline_markdown(content));
out.push_str("](");
out.push_str(uri);
out.push(')');
}
}
}

Expand Down
29 changes: 17 additions & 12 deletions src/parser_adapter/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
//! | `AutoNumber` / `NewNumber` / `PageNumberPos` / `PageHide` | silently skipped (page-numbering chrome, no body content) |
//! | `Unknown` | `LossEntry(Other, "unknown control 0x…")` |

use rhwp::model::control::Control;
use rhwp::model::control::{Control, FieldType};
use rhwp::model::header_footer::HeaderFooterApply;
use rhwp::model::paragraph::Paragraph;
use rhwp::model::shape::{CommonObjAttr, ShapeObject};
Expand Down Expand Up @@ -203,12 +203,11 @@ pub(crate) fn convert_control(
// the URL (it sets `url = ""`). HWP5/HWPX hyperlinks instead arrive as
// `Control::Field` and keep their anchor text in the body run.
//
// DocLang has <href uri="">, but the crate IR exposes no inline href
// node yet (Inline has Text/Styled/FootnoteRef/LineBreak/Tab only) and
// emitting one correctly needs field-range splicing — deferred to v2
// (see docs/v2-known-limitations.md). For now rescue the display text
// as a plain block so it is not silently dropped, and record the loss
// of the link semantics / URL.
// HWP5/HWPX hyperlinks are emitted as inline `<href>` (via the
// paragraph's field_ranges in inline.rs), but this HWP3 path has no
// extracted URL to anchor one. For now rescue the display text as a
// plain block so it is not silently dropped, and record the loss of
// the link semantics / URL.
loss.push(LossEntry {
kind: LossKind::Other("hyperlink".to_string()),
location: ctx.location.to_string(),
Expand Down Expand Up @@ -256,11 +255,17 @@ pub(crate) fn convert_control(
}

Control::Field(f) => {
loss.push(LossEntry {
kind: LossKind::Other("field".to_string()),
location: ctx.location.to_string(),
detail: format!("field type={} command={:?}", f.field_type_str(), f.command),
});
// Hyperlink fields are now represented inline as <href> (resolved via
// the paragraph's field_ranges in inline.rs), so recording a
// block-level loss for them would be misleading. Other field kinds
// have no inline representation yet and are still recorded.
if f.field_type != FieldType::Hyperlink {
loss.push(LossEntry {
kind: LossKind::Other("field".to_string()),
location: ctx.location.to_string(),
detail: format!("field type={} command={:?}", f.field_type_str(), f.command),
});
}
ControlOutcome::empty()
}

Expand Down
54 changes: 46 additions & 8 deletions src/parser_adapter/geometry_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ use rhwp::renderer::render_tree::{BoundingBox, RenderNode, RenderNodeType};
use rhwp::DocumentCore;

use crate::ir::prov::{Location, LocationMap, Prov};
use crate::loss::report::{LossEntry, LossKind, LossReport};

/// A pixel-space bounding box accumulator (min/max corners).
#[derive(Clone, Copy)]
Expand Down Expand Up @@ -106,21 +107,42 @@ struct Accum {
/// Build the full `Prov -> Location` map for `data`.
///
/// `document` is the already-parsed model (used to resolve which body control a
/// per-page Header/Footer group belongs to). Returns an empty map on any layout
/// error for a page (best-effort: a page that fails to lay out contributes no
/// boxes rather than aborting the whole conversion).
pub(crate) fn build_location_map(data: &[u8], document: &Document) -> LocationMap {
/// per-page Header/Footer group belongs to). Best-effort: a page that fails to
/// lay out contributes no boxes rather than aborting the whole conversion.
///
/// Failures are surfaced through `loss` so a caller can tell *why* `<location>`
/// coverage is empty/partial instead of seeing a silent empty map:
/// - the whole layout core failing to build (no boxes at all), or
/// - individual pages failing to lay out (partial coverage).
pub(crate) fn build_location_map(
data: &[u8],
document: &Document,
loss: &mut LossReport,
) -> LocationMap {
let core = match DocumentCore::from_bytes(data) {
Ok(c) => c,
Err(_) => return LocationMap::new(),
Err(_) => {
loss.push(LossEntry {
kind: LossKind::Other("location-unavailable".to_string()),
location: "document".to_string(),
detail: "layout core failed to build; no <location> coordinates emitted"
.to_string(),
});
return LocationMap::new();
}
};
build_from_core(&core, document)
build_from_core(&core, document, loss)
}

/// Inner builder once a [`DocumentCore`] is available (kept separate so it can
/// be unit-tested with a synthetic core / document in the future).
fn build_from_core(core: &DocumentCore, document: &Document) -> LocationMap {
fn build_from_core(
core: &DocumentCore,
document: &Document,
loss: &mut LossReport,
) -> LocationMap {
let mut acc: HashMap<Prov, Accum> = HashMap::new();
let mut failed_pages: Vec<u32> = Vec::new();

// Precompute, per section, the (para, control) of its sole Header/Footer
// control so the per-page Header/Footer group bounds can be attached.
Expand All @@ -133,7 +155,10 @@ fn build_from_core(core: &DocumentCore, document: &Document) -> LocationMap {
for page in 0..core.page_count() {
let tree = match core.build_page_render_tree(page) {
Ok(t) => t,
Err(_) => continue,
Err(_) => {
failed_pages.push(page);
continue;
}
};
let (page_w, page_h, section) = page_dims(&tree.root);
let ctx = Ctx {
Expand All @@ -148,6 +173,19 @@ fn build_from_core(core: &DocumentCore, document: &Document) -> LocationMap {
walk(&tree.root, ctx, &hf, &table_dims, &mut acc);
}

if !failed_pages.is_empty() {
loss.push(LossEntry {
kind: LossKind::Other("location-page-layout-failed".to_string()),
location: "document".to_string(),
detail: format!(
"{} of {} page(s) failed to lay out; <location> coverage is partial (pages {:?})",
failed_pages.len(),
core.page_count(),
failed_pages
),
});
}

acc.into_iter()
.map(|(prov, a)| {
(
Expand Down
Loading
Loading