diff --git a/README.md b/README.md
index 194f3f6d..e6d5d33c 100644
--- a/README.md
+++ b/README.md
@@ -199,3 +199,9 @@ HWPX 내부 구조가 처음이라면 [내부 실전 가이드](docs/internals/)
## License · Maintainer
Apache-2.0 ([LICENSE](LICENSE) · [NOTICE](NOTICE)) — **Kohkyuhyun** [@airmang](https://github.com/airmang) · [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
+
+### Experimental native-preview scene
+
+Applications that provide glyph metrics can opt into the cache-independent
+[experimental scene API](docs/native-preview-scenes.md). Native font loading,
+rasterization and desktop file handling remain in the automation/plugin layers.
diff --git a/docs/architecture/module-ownership.json b/docs/architecture/module-ownership.json
index b101ebb3..fa67d420 100644
--- a/docs/architecture/module-ownership.json
+++ b/docs/architecture/module-ownership.json
@@ -734,6 +734,18 @@
"disposition": "core",
"approvedBy": "formfit-glyph-widths",
"rationale": "FormFit glyph widths beyond measure._GLYPHS: per face, the design advance of each glyph of the KS X 1001 symbols, the full-width forms and eight common Unicode blocks, read from the font files, and the face Hancom lays a glyph out from when a face lacks it. Data only; imports nothing."
+ },
+ {
+ "path": "src/hwpx/layout/body_scene.py",
+ "disposition": "core",
+ "approvedBy": "native-preview/PR-467",
+ "rationale": "Experimental format-native paragraph reflow and page geometry with injected glyph metrics; no font discovery, rasterizer, worker or host workflow."
+ },
+ {
+ "path": "src/hwpx/layout/object_scene.py",
+ "disposition": "core",
+ "approvedBy": "native-preview/PR-467",
+ "rationale": "Experimental format-native table, picture and page-story geometry composed with the body scene; image inspection and glyph metrics are injected by the application."
}
],
"removed": [
diff --git a/docs/coverage-ledger.json b/docs/coverage-ledger.json
index 89b6f42b..8dfc2744 100644
--- a/docs/coverage-ledger.json
+++ b/docs/coverage-ledger.json
@@ -59,8 +59,8 @@
"corpusOnly": 38,
"corpusObserved": 229,
"codeRead": 266,
- "codeWriteApi": 208,
- "codeWriteFrozenTemplate": 11,
+ "codeWriteApi": 209,
+ "codeWriteFrozenTemplate": 10,
"codeWriteNone": 126,
"capabilityMapped": 111,
"renderVerified": 132,
@@ -4100,7 +4100,7 @@
"corpusFrequency": 0.8987,
"corpusFileCount": 213,
"codeRead": true,
- "codeWrite": "frozen-template",
+ "codeWrite": "api",
"capabilityArea": "문단·표 저작/편집",
"capabilityStatus": "Parse·Preserve·Edit·Create·Render-verified",
"verificationBasis": "by-capability-area",
diff --git a/docs/coverage-ledger.md b/docs/coverage-ledger.md
index 491db604..3547fcc2 100644
--- a/docs/coverage-ledger.md
+++ b/docs/coverage-ledger.md
@@ -18,7 +18,7 @@
**5) 속성 축.** 요소별 관측 속성 **이름** 집합을 census가 함께 기록한다(`observedAttributes` 컬럼, 값 빈도까지는 이번 사이클 범위 밖 — 생성기 독스트링에 명시).
**6) openrate 코퍼스 환류(v4~v21).** `docs/openrate/report-v{4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21}.json`의 스트라타별 실한컴 수용(`render_checked>0`·`render_failed==0`, 구세대 스키마는 `opened==requested>0`도 함께)을 `verificationBasis`로 환류한다(`by-openrate-corpus`/`by-capability-area+openrate-corpus`) — 2026-08-04 감사 R4가 지목한 v4 배선(원 이름 `by-v4-corpus`)을 2026-08 사이클 6.5 트레인⑰에서 v8까지, 사이클 6.6 트레인⑳에서 v9까지, 사이클 6.7 트레인㉔에서 v10까지 확장하며 버전-중립 이름으로 바꿨다. 두 경로로 매핑한다: 이미 등록된 capabilityArea와 1:1 대응하는 스트라타는 `_OPENRATE_STRATUM_TO_CAPABILITY_AREA`(차트·체크박스·수식·각주 2종·테두리채우기·하이라이트·메모·그룹컨테이너·문서옵션호환성), capabilityArea가 아직 없거나 있어도 혼합 지원 영역이라 요소만 지목해야 하는 신규 능력(글꼴·탭·도형텍스트·캡션·쪽번호제어·문자서식·필드파라미터·arc/polygon·인라인 원자 3종)은 `_OPENRATE_STRATUM_TO_ELEMENTS`로 요소를 직접 지목한다 — 둘 다 근거는 각 생성기 스크립트 독스트링이 실제로 부른다고 명시하는 것뿐(무근거 매핑 금지, `fieldBegin`을 일부러 안 매핑한 것과 같은 원칙).
-**전 vs 후 (감사가 하한을 인용한 것과 같은 슬라이스 — corpusFileCount>0인 요소만)**: 감사 인용 하한은 관측 228건 중 write=none **70** · read=none **56** · frozen-template **28**([감사 판정문](2026-08-04-completeness-audit-verdict.md) 요약표). 이 원장 재생성 기준으로는 관측 229건 중 write=none **21** · read=none **2** · frozen-template **11**. **주의**: 두 population이 다르다(모집단을 재정의했다 — 위 4항목) — 이 비교는 "같은 잣대로 다시 잰 정확한 델타"가 아니라 분류기 수리가 방향대로 움직였는지의 참고 신호다. 분류기 수리 자체의 정확도 증거는 위 1~3항의 요소별 재현 로그가 1차 근거다.
+**전 vs 후 (감사가 하한을 인용한 것과 같은 슬라이스 — corpusFileCount>0인 요소만)**: 감사 인용 하한은 관측 228건 중 write=none **70** · read=none **56** · frozen-template **28**([감사 판정문](2026-08-04-completeness-audit-verdict.md) 요약표). 이 원장 재생성 기준으로는 관측 229건 중 write=none **21** · read=none **2** · frozen-template **10**. **주의**: 두 population이 다르다(모집단을 재정의했다 — 위 4항목) — 이 비교는 "같은 잣대로 다시 잰 정확한 델타"가 아니라 분류기 수리가 방향대로 움직였는지의 참고 신호다. 분류기 수리 자체의 정확도 증거는 위 1~3항의 요소별 재현 로그가 1차 근거다.
## 전체 통계
@@ -29,8 +29,8 @@
| 코퍼스에만 있음(스키마 미대응) | 38 | 11.0% |
| 실코퍼스에서 관측(빈도>0) | 229 | 66.4% |
| 코드 읽기 | 266 | 77.1% |
-| 코드 쓰기(api) | 208 | 60.3% |
-| 쓰기 frozen-template | 11 | 3.2% |
+| 코드 쓰기(api) | 209 | 60.6% |
+| 쓰기 frozen-template | 10 | 2.9% |
| 쓰기 none | 126 | 36.5% |
| 능력 영역 매핑됨 | 111 | 32.2% |
| Render-verified(매핑 근거) | 132 | 38.3% |
@@ -60,7 +60,6 @@
| `hc:right` | 1.0000 | 237 | True | frozen-template | — |
| `hh:head` | 1.0000 | 237 | True | frozen-template | — |
| `hh:typeInfo` | 0.9747 | 231 | True | frozen-template | — |
-| `hp:lineseg` | 0.8987 | 213 | True | frozen-template | 문단·표 저작/편집 |
| `hh:metaTag` | 0.0422 | 10 | True | frozen-template | — |
| `hh:forbiddenWord` | 0.0127 | 3 | True | none | — |
| `hh:forbiddenWordList` | 0.0127 | 3 | True | none | — |
@@ -93,7 +92,7 @@
| `hh` | 126 | 125 | 64 | 72 | 63 | 3 | 60 |
| `hhs` | 10 | 10 | 0 | 10 | 0 | 0 | 10 |
| `hm` | 2 | 2 | 0 | 2 | 0 | 0 | 2 |
-| `hp` | 171 | 161 | 132 | 146 | 120 | 1 | 50 |
+| `hp` | 171 | 161 | 132 | 146 | 121 | 0 | 50 |
| `hs` | 1 | 1 | 1 | 1 | 1 | 0 | 0 |
| `hv` | 2 | 1 | 1 | 2 | 1 | 0 | 1 |
diff --git a/docs/native-preview-scenes.md b/docs/native-preview-scenes.md
new file mode 100644
index 00000000..946cde9d
--- /dev/null
+++ b/docs/native-preview-scenes.md
@@ -0,0 +1,28 @@
+# Experimental preview scenes
+
+`hwpx.layout.body_scene` and `hwpx.layout.object_scene` produce an experimental,
+cache-independent page scene from HWPX bytes. A caller supplies glyph metrics;
+core does not discover fonts, rasterize images, start workers or load an office
+application. No native rendering dependency is added to the default install.
+
+`build_body_scene(data, provider)` handles the bounded body slice.
+`build_document_scene(data, provider)` also handles supported tables, pictures
+and page stories. `BodySceneSession(provider)` can reuse owned paragraph
+measurements across edits; returned scenes are detached from its cache.
+
+The provider implements `shape(text, attributes, style)` and `manifest()`.
+Page-number stories additionally use `font_metrics(family, height)`, and pictures
+use `inspect_picture(raw_bytes)`. Font metrics and picture decoding belong to the
+application layer. Missing fonts and unsupported controls must produce an
+unsupported result with no partial pages. Requested font substitutions remain an
+explicit application decision.
+
+Coordinates use HWPUNIT. The returned dictionary includes `status`, `pages`,
+`unsupported`, `errors`, source/layout hashes and `qualityValidated=False`.
+Saved line-segment arrays and cell sub-list measurements are excluded before
+measurement. The `ignore`, `remove` and `poison` cache modes support metamorphic
+testing; none authorizes using source layout caches as measurements.
+
+This is a versioned experimental scene format, not a fidelity guarantee or a
+replacement for an independent office-rendering oracle. Unsupported layout
+policies fail closed. The existing HTML preview and save-gate APIs are unchanged.
diff --git a/docs/python-example-ledger.json b/docs/python-example-ledger.json
index 3fa2c268..51c53fce 100644
--- a/docs/python-example-ledger.json
+++ b/docs/python-example-ledger.json
@@ -16,7 +16,7 @@
"fenceCount": 115,
"sourceSha256": "b924d97f4a04691b26d3098514b1e2db25faac02715aa9646fedc91ca5182444",
"manualSha256": {
- "README.md": "d6d08f94d81e4b7634c80d6738b933050e4ad7e466c1036d68649839760bacf3",
+ "README.md": "55067f4daa444dd2bbd7a142296933512af3ebdd1636b40c1337661888a25ca7",
"README_EN.md": "da40f5ba537c1d2b05c5afdedc097f6c8effbf6e2b3b8fcb26e2bf5712d098a0",
"docs/quickstart.md": "44d8ca82ee70c0e3dfdf087713a4e4ce935e64cd3f6a546311f95b054a285a18",
"docs/recipes-traversal.md": "b63f42b1a6ed23812688599474dcf83300509b86a94b5daceae679bfdbde9343",
diff --git a/pyproject.toml b/pyproject.toml
index 79529b31..8bf71e43 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -153,6 +153,8 @@ python_version = "3.10"
follow_imports = "skip"
files = [
"src/hwpx/document.py",
+ "src/hwpx/layout/body_scene.py",
+ "src/hwpx/layout/object_scene.py",
"src/hwpx/errors.py",
"src/hwpx/mutation_report.py",
"src/hwpx/capabilities.py",
@@ -365,6 +367,8 @@ ignore_missing_imports = true
# both central facades and every extracted/touched runtime owner to the gate.
include = [
"src/hwpx/document.py",
+ "src/hwpx/layout/body_scene.py",
+ "src/hwpx/layout/object_scene.py",
"src/hwpx/errors.py",
"src/hwpx/mutation_report.py",
"src/hwpx/capabilities.py",
diff --git a/src/hwpx/layout/body_scene.py b/src/hwpx/layout/body_scene.py
new file mode 100644
index 00000000..3e5aa086
--- /dev/null
+++ b/src/hwpx/layout/body_scene.py
@@ -0,0 +1,319 @@
+"""Experimental cache-blind single-column body scene. No oracle dependency.
+
+GlyphProvider is the native/WASM portability seam; this module owns HWPX
+semantics and Hancom layout policy. Unsupported documents produce no pages.
+"""
+from __future__ import annotations
+import hashlib
+import io
+import json
+import copy
+from collections import OrderedDict
+from typing import Any, Protocol
+from hwpx import HwpxDocument
+from ..errors import HwpxValueError
+from .pages import HP, HH, _Measure, _pitch, _line_widths, _t_text
+from ..form_fit.measure import hancom_line_starts
+
+VERSION = 'body-scene-0.3'
+
+class GlyphProvider(Protocol):
+ def shape(self, text: str, attributes: dict, style: object) -> list[dict]: ...
+ def manifest(self) -> dict: ...
+
+def digest(obj):
+ return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
+
+class BodySceneSession:
+ """Incremental paragraph reflow with full downstream pagination.
+
+ Cached values contain only our own unpositioned paragraph measurements.
+ A session owns one immutable glyph provider. Header or paper changes
+ invalidate all entries; paragraph edits invalidate their content keys.
+ Returned scenes are deep copies and cannot corrupt later renders.
+ """
+ def __init__(self, provider: GlyphProvider, *, max_paragraphs=256):
+ if max_paragraphs < 1:
+ raise HwpxValueError('max_paragraphs must be positive', code='preview-cache-capacity-invalid',
+ context={'maxParagraphs': max_paragraphs}, suggestion='Use a positive paragraph cache capacity.')
+ self.provider = provider
+ self.max_paragraphs = max_paragraphs
+ self._entries: OrderedDict[str, Any] = OrderedDict()
+ self._environment = None
+
+ def clear(self):
+ self._entries.clear()
+ self._environment = None
+
+ def render(self, data: bytes, *, cache_mode='ignore', inject_test_cache=False):
+ if getattr(self, '_bound_provider', self.provider) is not self.provider:
+ self.clear()
+ self._bound_provider = self.provider
+ return build_body_scene(data, self.provider, cache_mode=cache_mode,
+ inject_test_cache=inject_test_cache, _paragraph_cache=self)
+
+ def _begin(self, environment):
+ invalidated = environment != self._environment
+ if invalidated:
+ self._entries.clear()
+ self._environment = environment
+ self._stats: dict[str, Any] = dict(mode='incremental_paragraphs_full_pagination',
+ environmentInvalidated=invalidated,
+ paragraphsRecomputed=0, paragraphsReused=0,
+ sourceLayoutCacheUsed=False)
+
+ def _get(self, key):
+ if key not in self._entries:
+ self._stats['paragraphsRecomputed'] += 1
+ return None
+ self._stats['paragraphsReused'] += 1
+ self._entries.move_to_end(key)
+ return copy.deepcopy(self._entries[key])
+
+ def _put(self, key, value):
+ self._entries[key] = copy.deepcopy(value)
+ self._entries.move_to_end(key)
+ while len(self._entries) > self.max_paragraphs:
+ self._entries.popitem(last=False)
+
+def build_body_scene(data: bytes, provider: GlyphProvider, *, cache_mode='ignore', inject_test_cache=False, _paragraph_cache=None, _object_layout: bool | None=False) -> dict:
+ from lxml import etree # type: ignore[reportAttributeAccessIssue] # incomplete lxml typing
+ if cache_mode not in {'ignore','remove','poison'}:
+ raise HwpxValueError('unknown cache test mode', code='preview-cache-mode-invalid',
+ context={'cacheMode': cache_mode}, suggestion='Use ignore, remove, or poison.')
+ d = HwpxDocument.open(io.BytesIO(data)) # core OPC/XML guards
+ root: Any = d.oxml
+ if _object_layout is None:
+ # Detect scene controls only after the normal OPC/XML guards, using the
+ # already parsed tree rather than holding a second document in memory.
+ _object_layout = any(node.tag in {HP+'tbl', HP+'pic', HP+'header', HP+'pageNum'}
+ for section in root.sections for node in section.element.iter())
+ removed = 0
+ if inject_test_cache:
+ for section in root.sections:
+ for pi,p in enumerate(section.element.findall(HP+'p')):
+ a=etree.SubElement(p,HP+'linesegarray')
+ etree.SubElement(a,HP+'lineseg',dict(textpos='0',vertpos=str(pi*1600),vertsize='1000',textheight='1000',
+ baseline='850',spacing='600',horzpos='0',horzsize='48188',flags='393216'))
+ # Boundary: never read cache attributes. Mutation here is only an in-memory
+ # metamorphic fixture. Every mode drops these subtrees before measurement.
+ for section in root.sections:
+ for node in list(section.element.iter(HP+'linesegarray')):
+ if cache_mode == 'poison':
+ for seg in node:
+ seg.attrib.update(dict(textpos='7', horzsize='48190', horzpos='1500',
+ vertpos='3200', vertsize='1600', baseline='1250',
+ textheight='1600', spacing='600'))
+ removed += 1
+ node.getparent().remove(node)
+ if _object_layout:
+ for node in section.element.iter(HP+'subList'):
+ for key in ['textWidth','textHeight']:
+ node.attrib.pop(key,None)
+ scene: dict[str, Any] = dict(schemaVersion=1, algorithmVersion=VERSION, unit='HWPUNIT',
+ inputSha256=hashlib.sha256(data).hexdigest(), sourceCachePolicy='ignore',
+ cacheInputReads=0, cacheArraysExcluded=removed, status='supported',
+ unsupported=[], pages=[], offsetUnit='Python codepoint; hwpTextStart UTF16/control units',
+ substitutions=[], errors=[])
+ scene['qualityValidated']=False
+ scene['cacheInstrumentation']='source layout subtrees excluded before semantic measurement; no cache field getter exposed'
+ def reject(path, reason): scene['unsupported'].append(dict(path=path, reason=reason))
+ if len(root.sections) != 1: reject('sections', 'single section only')
+ allowed = {'sec','p','run','t','lineBreak','fwSpace','secPr','ctrl','colPr','pagePr','margin',
+ 'startNum','visibility','grid','footNotePr','endNotePr','autoNumFormat',
+ 'noteLine','noteSpacing','numbering','placement','pageBorderFill','offset',
+ 'masterPage','parameters','stringParam','integerParam','lineNumberShape'}
+ if _object_layout:
+ from .object_scene import OBJECT_ELEMENTS
+ allowed |= OBJECT_ELEMENTS | {'header','pageNum'}
+ # Section defaults are declarative, not visible footnote/header content.
+ for si, section in enumerate(root.sections):
+ for el in section.element.iter():
+ local = el.tag.rsplit('}',1)[-1]
+ if local not in allowed: reject(f'section/{si}/{local}', 'element outside body slice')
+ if local == 'lineNumberShape' and el.get('restartType','0') != '0': reject(f'section/{si}/lineNumberShape','active line numbers')
+ if local == 'secPr' and el.get('textDirection','HORIZONTAL') != 'HORIZONTAL': reject(f'section/{si}/secPr','vertical text')
+ if local == 'grid' and any(el.get(k,'0')!='0' for k in ['lineGrid','charGrid','wonggojiFormat']): reject(f'section/{si}/grid','active grid')
+ for col in section.element.iter(HP+'colPr'):
+ if col.get('colCount','1') != '1': reject(f'section/{si}/colPr', 'multiple columns')
+ semantic = [etree.tostring(h.element).decode() for h in root.headers] + [etree.tostring(s.element).decode() for s in root.sections]
+ scene['semanticSha256'] = digest(semantic)
+ if scene['unsupported']:
+ scene.update(status='unsupported', **provider.manifest()); return scene
+ measure = _Measure(root)
+ sec = root.sections[0]
+ pagepr = next(sec.element.iter(HP+'pagePr'), None)
+ if pagepr is None: reject('pagePr', 'missing paper geometry')
+ else:
+ margin = pagepr.find(HP+'margin')
+ if margin is None:
+ reject('pagePr/margin', 'missing page margins')
+ scene.update(status='unsupported', **provider.manifest())
+ return scene
+ paper = {k:int(pagepr.get(k,0)) for k in ['width','height']}
+ margins = {k:int(margin.get(k,0)) for k in ['left','right','top','bottom','header','footer','gutter']}
+ if paper['width'] != 59528 or paper['height'] not in {84186,84188} or margins['gutter'] or pagepr.get('landscape','WIDELY') not in {'WIDELY','PORTRAIT','0'}:
+ reject('pagePr', 'slice requires portrait A4 with zero gutter')
+ if scene['unsupported']:
+ scene.update(status='unsupported', **provider.manifest()); return scene
+ body = dict(x=margins['left'],y=margins['top']+margins['header'],width=paper['width']-margins['left']-margins['right'],
+ height=paper['height']-sum(margins[k] for k in ['top','bottom','header','footer']))
+ if _object_layout:
+ from .object_scene import paginate_objects,render_headers,render_page_numbers
+ paginate_objects(data, root, measure, body, paper, provider, scene, reject)
+ if not scene['unsupported'] and not scene['errors']:
+ render_headers(data,root,measure,body,paper,margins,provider,scene,reject)
+ if not scene['unsupported']:render_page_numbers(root,body,paper,margins,provider,scene,reject)
+ if scene['unsupported']: scene['status']='unsupported'; scene['pages']=[]
+ elif scene['errors']: scene['status']='failed'; scene['pages']=[]
+ scene.update(provider.manifest())
+ scene['layoutSha256']=digest(scene['pages'])
+ return scene
+ def page():
+ scene['pages'].append(dict(index=len(scene['pages']),size=paper.copy(),bodyRect=body.copy(),lines=[]))
+ page(); y=0; pending=0
+ if _paragraph_cache is not None:
+ _paragraph_cache._begin(digest(dict(version=VERSION, body=body, paper=paper,
+ headers=[etree.tostring(h.element).decode() for h in root.headers])))
+ for pi, paragraph in enumerate(sec.element.findall(HP+'p')):
+ key = hashlib.sha256(etree.tostring(paragraph)).hexdigest()
+ cached = _paragraph_cache._get(key) if _paragraph_cache is not None else None
+ if cached is None:
+ prepared, shape = _prepare_paragraph(paragraph, pi, root, measure, body, provider,
+ reject, lambda: bool(scene['unsupported']))
+ if scene['unsupported']: continue
+ if _paragraph_cache is not None: _paragraph_cache._put(key, (prepared, shape))
+ else:
+ prepared, shape = cached
+ for line in prepared: line['paragraphId'] = pi
+ if paragraph.get('pageBreak')=='1' and scene['pages'][-1]['lines']: page(); y=0; pending=0
+ y+=pending+shape.prev
+ block=sum(l['pitch'] for l in prepared[:-1])+prepared[-1]['height']
+ if shape.flags.get('keepLines') in {'1','true'} and y+block>=body['height'] and block
=body['height'] and scene['pages'][-1]['lines']: page(); y=0
+ line['y']=body['y']+y; line['baseline']+=line['y']
+ for g in line['glyphRuns']:
+ g['origin'][1]+=line['y']
+ b=g['inkBounds']; g['inkBounds']=[b[0]+g['origin'][0],b[1]+g['origin'][1],b[2]+g['origin'][0],b[3]+g['origin'][1]]
+ if g['glyphId'] and (g['inkBounds'][0]<0 or g['inkBounds'][1]<0 or g['inkBounds'][2]>paper['width'] or g['inkBounds'][3]>paper['height']): scene['errors'].append(f'p{pi} clipped glyph')
+ scene['pages'][-1]['lines'].append(line); y+=line['pitch']
+ pending=shape.next
+ if scene['unsupported']: scene['status']='unsupported'; scene['pages']=[]
+ elif len(scene['pages'])>2: reject('pages','more than two logical pages'); scene['status']='unsupported'; scene['pages']=[]
+ elif scene['errors']: scene['status']='failed'
+ scene.update(provider.manifest())
+ scene['layoutSha256']=digest(scene['pages'])
+ if _paragraph_cache is not None: scene['incremental'] = _paragraph_cache._stats.copy()
+ return scene
+
+def _prepare_paragraph(paragraph, pi, root, measure, body, provider, reject, rejected, *, line_boxes=None):
+ pr = paragraph.get('paraPrIDRef','0'); shape=measure.shape(pr)
+ prop=next(h for h in measure._header.iter(HH+'paraPr') if h.get('id') == pr)
+ align=prop.find(HH+'align'); alignment=align.get('horizontal','LEFT') if align is not None else 'LEFT'
+ if alignment not in {'LEFT','RIGHT','CENTER','JUSTIFY'}: reject(f'p/{pi}', f'alignment {alignment}')
+ if measure.numbered(pr) or measure.headed(paragraph): reject(f'p/{pi}', 'numbering/bullet')
+ if prop.get('textDir','LTR') != 'LTR': reject(f'p/{pi}', 'non-LTR')
+ if any(shape.flags.get(k) in {'1','true'} for k in ['keepWithNext','widowOrphan','pageBreakBefore']):
+ reject(f'p/{pi}', 'keepWithNext/widowOrphan/pageBreakBefore not implemented')
+ if shape.font_line: reject(f'p/{pi}', 'fontLineHeight requires separate font metrics calibration')
+ chars=[]; text=''; empty_end=1000; hwp_offset=0; hwp_positions=[]; control_height=0
+ for ri,run in enumerate(paragraph.findall(HP+'run')):
+ ref=run.get('charPrIDRef','0'); char=root.char_property(ref)
+ attrs: dict[str, Any] = dict(char.attributes) if char is not None else {'height':'1000'}
+ el=next(e for e in measure._header.iter(HH+'charPr') if e.get('id')==ref)
+ if el.get('useKerning','0') in {'1','true'}: reject(f'p/{pi}/run/{ri}', 'requested kerning outside feature-off slice')
+ for name in ['strikeout','outline','shadow','emboss','engrave','supscript','subscript']:
+ effect=el.find(HH+name)
+ if effect is not None and effect.get('type','NONE') != 'NONE': reject(f'p/{pi}/run/{ri}', 'unsupported character effect '+name)
+ attrs.update(bold=el.find(HH+'bold') is not None, italic=el.find(HH+'italic') is not None,
+ underline=el.find(HH+'underline').get('type','NONE') != 'NONE' if el.find(HH+'underline') is not None else False)
+ style=measure.style(pr,[ref]); attrs['height']=measure.char_height(ref)
+ empty_end=attrs['height']
+ if style.scripts and any((r,s)!=(style.ratio,style.spacing) for _,r,s in style.scripts):
+ reject(f'p/{pi}/run/{ri}', 'separate script scaling not implemented')
+ for child in run:
+ if child.tag == HP+'secPr' or child.tag == HP+'ctrl':
+ hwp_offset+=8; control_height=max(control_height,attrs['height'])
+ elif child.tag == HP+'t':
+ part=_t_text(child)
+ fixed_spaces=set();cursor=len(child.text or '')
+ for inline_child in child:
+ if inline_child.tag==HP+'fwSpace':fixed_spaces.add(cursor)
+ cursor+=1+len(inline_child.tail or '')
+ if any(not (32<=ord(c)<127 or 0xAC00<=ord(c)<=0xD7A3 or c in '\n“”‘’·—…\u3000') for c in part):
+ reject(f'p/{pi}/run/{ri}', 'character outside modern Hangul/ASCII/punctuation')
+ try: glyphs=provider.shape(part,attrs,style)
+ except Exception as exc:
+ reject(f'p/{pi}/run/{ri}', str(exc)); glyphs=[]
+ if len(glyphs)!=len(part): reject(f'p/{pi}/run/{ri}', 'non-one-codepoint cluster (not supported)')
+ for gi,(c,g) in enumerate(zip(part,glyphs)):
+ if gi in fixed_spaces:
+ # A tagged Hancom fixed-width blank is a quarter em,
+ # independent of font advance, ratio and tracking.
+ g.update(glyphId=0,advance=attrs['height']/4,spacingAdvance=0,
+ rawAdvance=attrs['height']/4,inkBounds=[0,0,0,0])
+ hwp_positions.append(hwp_offset); hwp_offset+=1
+ chars.append(dict(glyph=g,attributes=attrs,style=style,runId=ri,fixedSpace=gi in fixed_spaces)); text+=c
+ if rejected(): return None, shape
+ pstyle=measure.style(pr,[r.get('charPrIDRef','0') for r in paragraph.findall(HP+'run')] or ['0'])
+ widths=_line_widths(shape,body['width'],pstyle)
+ if line_boxes is not None:
+ widths=[b['width']-shape.left-shape.right for b in line_boxes]
+ if shape.indent or '\n' in text:reject(f'p/{pi}','wrapped object slice excludes indent and explicit line break')
+ starts=[]; base=0
+ layout_text=''.join('\ufdd0' if c=='\u3000' and not chars[i]['fixedSpace'] else c for i,c in enumerate(text))
+ for part in layout_text.split('\n'):
+ span=chars[base:base+len(part)]
+ advances={i:c['glyph']['advance'] for i,c in enumerate(span)}
+ starts += [base+s for s in hancom_line_starts(part,widths,empty_end/100,pstyle,
+ [c['attributes']['height']/100 for c in span], [c['style'] for c in span], advances)]
+ base+=len(part)+1
+ prepared=[]
+ for li,(start,stop) in enumerate(zip(starts,starts[1:]+[len(text)])):
+ end=stop-1 if stop>start and text[stop-1]=='\n' else stop
+ span=chars[start:end]; height=max([c['attributes']['height'] for c in span]+[empty_end if stop==len(text) else 0,control_height if start==0 else 0]) or empty_end
+ if shape.font_line:
+ height=max([measure.font_line(c['attributes']['height'],c['style']) for c in span]+[measure.font_line(empty_end,pstyle)])
+ pitch=_pitch(shape.kind,shape.value,height)
+ baseline=height*0.85 # explicit prototype hypothesis; comparator must judge
+ room=widths[min(li,len(widths)-1)] if line_boxes is not None else widths[min(li,1)]
+ visible=len(text[start:end].rstrip(' '))
+ total=sum(c['glyph']['advance'] for c in span[:visible])
+ if visible: total-=span[visible-1]['glyph']['spacingAdvance']
+ natural=total; offset=0; extras={}
+ if alignment=='RIGHT': offset=room-total
+ elif alignment=='CENTER': offset=(room-total)/2
+ elif alignment=='JUSTIFY' and stop1 or margins['footer']<=0:reject('pageNum','invalid side character/footer region');return
+ visibility=next(root.sections[0].element.iter(HP+'visibility'),None)
+ hidden_first=visibility is not None and visibility.get('hideFirstPageNum','0')=='1'
+ start=next(root.sections[0].element.iter(HP+'startNum'),None)
+ first=max(1,integer(start,'page',1))
+ affected=[pg['index'] for pg in scene['pages'] if any(l['paragraphId']==pi and l.get('story',{}).get('region')!='header' for l in pg['lines'])]
+ if not affected:reject('pageNum','page number anchor absent');return
+ family='함초롬돋움';height=1000;style=TextStyle(hangul_face=family,glyph_face=family)
+ try:metrics=provider.font_metrics(family,height)
+ except (AttributeError,ValueError) as exc:reject('pageNum','font metrics unavailable: '+str(exc));return
+ baseline=paper['height']-margins['bottom']-metrics['windowsDescent']
+ for pg in scene['pages']:
+ if pg['index']body['width']:
+ reject('pageNum','page label exceeds reserved footer');return
+ pg['lines'].append(line)
+ scene['pageNumberPolicy']=dict(family=family,height=height,baseline='paper bottom minus bottom margin minus Windows font descent',position=position,format='DIGIT')
+
+def signed32(n,k):
+ value=integer(n,k)
+ return value-(1<<32) if (1<<31)<=value<(1<<32) else value
+def margins(n):return {k:integer(n,k) for k in ['left','right','top','bottom']}
+
+def position_line(line, y, paper, scene):
+ line=copy.deepcopy(line);line['y']+=y;line['baseline']+=y
+ for g in line['glyphRuns']:
+ g['origin'][1]+=y
+ b=g['inkBounds'];g['inkBounds']=[b[0]+g['origin'][0],b[1]+g['origin'][1],b[2]+g['origin'][0],b[3]+g['origin'][1]]
+ if g['glyphId'] and (min(g['inkBounds'][:2])<0 or g['inkBounds'][2]>paper['width'] or g['inkBounds'][3]>paper['height']):
+ scene['errors'].append('glyph exceeds logical paper')
+ return line
+
+def border_style(root,ref,reject):
+ el=next((n for h in root.headers for n in h.element.iter(HH+'borderFill') if n.get('id')==ref),None)
+ if el is None:reject('borderFill','missing cell border style');return {}
+ for name in ['slash','backSlash']:
+ n=el.find(HH+name)
+ if n is not None and n.get('type','NONE')!='NONE':reject('borderFill','diagonal borders')
+ if any(el.get(k,'0')!='0' for k in ['threeD','shadow']):reject('borderFill','3D/shadow')
+ if el.get('centerLine','NONE')!='NONE':reject('borderFill','center line')
+ cell_fill(root,ref,reject)
+ edges={}
+ for name in ['left','right','top','bottom']:
+ n=el.find(HH+name+'Border')
+ if n is None:reject('borderFill','incomplete cell borders');continue
+ if n.get('type') not in {'NONE','SOLID','DASH'}:reject('borderFill','border pattern not implemented');continue
+ try:w=float(n.get('width','0 mm').split()[0])*7200/25.4
+ except ValueError:reject('borderFill','invalid border width');continue
+ edges[name]=dict(type=n.get('type'),width=w,color=n.get('color','#000000'))
+ return edges
+
+def cell_fill(root,ref,reject):
+ """An opaque solid cell brush. Pattern/image/gradient policies remain explicit."""
+ import re
+ el=next((n for h in root.headers for n in h.element.iter(HH+'borderFill') if n.get('id')==ref),None)
+ if el is None:return None
+ fill=el.find(HC+'fillBrush')
+ if fill is None:return None
+ brush=fill.find(HC+'winBrush')
+ if brush is None or len(fill)!=1:
+ reject('borderFill','gradient/image fill not implemented');return None
+ if brush.get('hatchStyle','NONE') not in {'NONE','none',''} or brush.get('hatchColor','none') not in {'NONE','none'}:
+ reject('borderFill','pattern fill not implemented');return None
+ if brush.get('alpha','0')!='0':reject('borderFill','transparent fill not implemented');return None
+ color=brush.get('faceColor','none')
+ if color in {'NONE','none'}:return None
+ if not re.fullmatch(r'#[0-9A-Fa-f]{6}',color):reject('borderFill','invalid fill color');return None
+ return color
+
+def nested_cell_scene(sl,pi,r,c,root,measure,box,provider,data,paper,reject):
+ from types import SimpleNamespace
+ from lxml import etree as ET # type: ignore[reportAttributeAccessIssue] # incomplete lxml typing
+ depth=getattr(root,'objectDepth',0)
+ if data is None or paper is None or depth>=8:
+ reject('tbl/tc','nested object context/depth limit');return [],[]
+ section=ET.Element(root.sections[0].element.tag)
+ for p in sl.findall(HP+'p'):section.append(copy.deepcopy(p))
+ proxy=SimpleNamespace(headers=root.headers,sections=[SimpleNamespace(element=section)],char_property=root.char_property,objectDepth=depth+1)
+ local=dict(pages=[],unsupported=[],errors=[])
+ local_paper=dict(width=box['x']+box['width'],height=box['height'])
+ paginate_objects(data,proxy,measure,box,local_paper,provider,local,lambda path,reason:local['unsupported'].append(dict(path=path,reason=reason)))
+ for issue in local['unsupported']:reject('tbl/tc/'+issue['path'],issue['reason'])
+ if local['unsupported']:return [],[]
+ if len(local['pages'])!=1 or local['errors']:
+ reject('tbl/tc','nested story requires recursive continuation');return [],[]
+ lines=local['pages'][0]['lines'];objects=local['pages'][0]['objects']
+ ancestor=dict(tableParagraph=pi,row=r,col=c)
+ for line in lines:
+ story=line['story']
+ if story['kind']=='body':line['story']=dict(kind='cell',tableParagraph=pi,row=r,col=c,paragraph=story['paragraph'])
+ else:line['story']=dict(story,ancestors=[ancestor]+story.get('ancestors',[]))
+ for g in line['glyphRuns']:
+ b=g['inkBounds'];x,y=g['origin'];g['inkBounds']=[b[0]-x,b[1]-y,b[2]-x,b[3]-y]
+ for obj in objects:obj['ancestors']=[ancestor]+obj.get('ancestors',[])
+ return lines,objects
+
+def translated_object(obj,x,y):
+ obj=copy.deepcopy(obj)
+ obj['rect'][0]+=x;obj['rect'][1]+=y
+ for c in obj.get('cells',[]):
+ c['rect'][0]+=x;c['rect'][1]+=y
+ c['textOrigin'][0]+=x;c['textOrigin'][1]+=y
+ return obj
+
+def table_model(table, pi, root, measure, body, provider, scene, reject,*,data=None,paper=None):
+ pos=table.find(HP+'pos');sz=table.find(HP+'sz');outer=margins(table.find(HP+'outMargin'))
+ if pos is None or sz is None:reject(f'p/{pi}/tbl','missing object size/anchor');return None
+ if any(sz.get(k,'ABSOLUTE')!='ABSOLUTE' for k in ['widthRelTo','heightRelTo']):reject('tbl','relative size')
+ if any(integer(pos,k) for k in ['horzOffset','vertOffset','allowOverlap']):reject('tbl','offset/overlapping table')
+ if pos.get('treatAsChar') not in {'0','1'}:reject('tbl','invalid table placement')
+ inline=pos.get('treatAsChar')=='1'
+ if table.get('textWrap')!='TOP_AND_BOTTOM' or integer(table,'cellSpacing'):reject('tbl','wrap or cell spacing')
+ if table.get('pageBreak','CELL') not in {'CELL','TABLE'}:reject('tbl','cell-content split not implemented')
+ if pos.get('horzAlign','LEFT') not in {'LEFT','RIGHT'} or pos.get('vertAlign','TOP')!='TOP':reject('tbl','object alignment')
+ if not inline and (pos.get('vertRelTo','PARA')!='PARA' or pos.get('horzRelTo','COLUMN') not in {'COLUMN','PARA'}):reject('tbl','anchor reference')
+ nr=integer(table,'rowCnt');nc=integer(table,'colCnt');width=integer(sz,'width')
+ extent=width if inline else width+outer['left']
+ if nr<1 or nc<1 or nr*nc>10000 or width<=0 or extent>body['width']:reject('tbl','invalid/outside table geometry');return None
+ columns: list[Any] = [None]*nc;cells=[];occupied=set();row_heights=[0]*nr
+ for tr in table.findall(HP+'tr'):
+ for tc in tr.findall(HP+'tc'):
+ addr=tc.find(HP+'cellAddr');span=tc.find(HP+'cellSpan');csz=tc.find(HP+'cellSz');sl=tc.find(HP+'subList')
+ if any(n is None for n in [addr,span,csz,sl]):reject('tbl/tc','incomplete cell');continue
+ r=integer(addr,'rowAddr');c=integer(addr,'colAddr');rs=integer(span,'rowSpan',1);cols=integer(span,'colSpan',1)
+ if not (0<=r=1 and cols>=1 and r+rs<=nr and c+cols<=nc):reject('tbl/tc','invalid span');continue
+ coverage={(rr,cc) for rr in range(r,r+rs) for cc in range(c,c+cols)}
+ if coverage & occupied:reject('tbl/tc','overlapping cell spans');continue
+ occupied|=coverage
+ w=integer(csz,'width');minimum=integer(csz,'height')
+ if cols==1:
+ if columns[c] is not None and columns[c]!=w:reject('tbl','inconsistent column widths')
+ columns[c]=w
+ cm=margins(tc.find(HP+'cellMargin') if tc.get('hasMargin')=='1' else table.find(HP+'inMargin'))
+ if w<=sum(cm[k] for k in ['left','right']) or minimum<0:reject('tbl/tc','non-positive cell body');continue
+ if sl.get('textDirection','HORIZONTAL')!='HORIZONTAL' or sl.get('lineWrap','BREAK')!='BREAK':reject('tbl/tc','cell text policy')
+ if any(sl.get(k,'0') not in {'','0'} for k in ['linkListIDRef','linkListNextIDRef']):reject('tbl/tc','linked text')
+ if any(sl.get(k,'0')!='0' for k in ['hasTextRef','hasNumRef']):reject('tbl/tc','referenced text/number')
+ align=sl.get('vertAlign','TOP')
+ if align not in {'TOP','CENTER','BOTTOM'}:reject('tbl/tc','vertical alignment')
+ box=dict(x=cm['left'],y=0,width=w-cm['left']-cm['right'],height=body['height'])
+ lines=[];y=0;pending=0;nested=[]
+ has_nested=any(n.tag in {HP+'tbl',HP+'pic'} for n in sl.iter())
+ if has_nested:
+ box['height']-=cm['top']+cm['bottom']
+ lines,nested=nested_cell_scene(sl,pi,r,c,root,measure,box,provider,data,paper,reject)
+ for cp,p in enumerate([] if has_nested else sl.findall(HP+'p')):
+ if p.get('pageBreak','0')!='0':reject('tbl/tc','explicit cell page break')
+ prepared,shape=_prepare_paragraph(p,pi,root,measure,box,provider,reject,lambda:bool(scene['unsupported']))
+ if prepared is None:continue
+ y+=pending+shape.prev
+ for li,l in enumerate(prepared):
+ l['y']=y;l['baseline']+=y
+ for g in l['glyphRuns']:g['origin'][1]+=y
+ l['paragraphBefore']=shape.prev if li==0 else 0
+ l['story']=dict(kind='cell',tableParagraph=pi,row=r,col=c,paragraph=cp)
+ lines.append(l);y+=l['pitch']
+ pending=shape.next
+ # Last paragraph ends at the em foot, excluding trailing line spacing.
+ content=max([l['y']+l['height'] for l in lines]+[o['rect'][1]+o['rect'][3] for o in nested]+[0])
+ needed=content+cm['top']+cm['bottom']
+ if rs==1:row_heights[r]=max(row_heights[r],minimum,needed)
+ cells.append(dict(row=r,col=c,rowSpan=rs,colSpan=cols,width=w,minimumHeight=minimum,requiredHeight=max(minimum,needed),margin=cm,
+ verticalAlignment=align,contentHeight=content,lines=lines,
+ borders=border_style(root,tc.get('borderFillIDRef',table.get('borderFillIDRef')),reject),
+ header=tc.get('header','0')=='1'))
+ fill=cell_fill(root,tc.get('borderFillIDRef',table.get('borderFillIDRef')),reject)
+ if fill is not None:cells[-1]['fill']=fill
+ if nested:cells[-1]['objects']=nested
+ if occupied!={(r,c) for r in range(nr) for c in range(nc)}:reject('tbl','incomplete table grid')
+ if any(w is None for w in columns):reject('tbl','cannot determine column widths without guessing');return None
+ if sum(columns)!=width:reject('tbl','table width disagrees with grid')
+ for c in cells:
+ if c['width']!=sum(columns[c['col']:c['col']+c['colSpan']]):reject('tbl','merged cell width disagrees with grid')
+ for c in sorted(cells,key=lambda c:(c['row']+c['rowSpan'],c['row'])):
+ r=c['row'];rs=c['rowSpan']
+ if rs>1:row_heights[r+rs-1]+=max(0,c['requiredHeight']-sum(row_heights[r:r+rs]))
+ repeat=table.get('repeatHeader','0')=='1'
+ if repeat and not all(c['header'] for c in cells if c['row']==0):reject('tbl','only a complete first header row supported')
+ if repeat and any(c['header'] for c in cells if c['row']>0):reject('tbl','multiple header rows')
+ if repeat and any(c['row']==0 and c['rowSpan']>1 for c in cells):reject('tbl','header spanning body rows')
+ model=dict(kind='table',paragraphId=pi,width=width,rows=row_heights,columns=columns,cells=cells,outer=outer,
+ repeatHeader=repeat,pageBreak=table.get('pageBreak','CELL'),inline=pos.get('treatAsChar')=='1',
+ rowBreakCut=_spare_cut(table))
+ if not inline and pos.get('horzAlign')=='RIGHT':model['horizontalAlignment']='RIGHT'
+ return model
+
+def picture_model(pic,pi,data,body,provider,reject,*,paper=None):
+ pos=pic.find(HP+'pos');sz=pic.find(HP+'sz');org=pic.find(HP+'orgSz');image=pic.find(HC+'img')
+ if any(n is None for n in [pos,sz,org,image]):reject('pic','incomplete picture');return None
+ width=integer(sz,'width');height=integer(sz,'height');outer=margins(pic.find(HP+'outMargin'))
+ inline=pos.get('treatAsChar')=='1'
+ if pic.get('textFlow','BOTH_SIDES') not in {'BOTH_SIDES','LEFT_ONLY','RIGHT_ONLY','LARGEST_ONLY'}:
+ reject('pic','invalid picture text flow')
+ if pos.get('treatAsChar') not in {'0','1'}:reject('pic','invalid placement')
+ if not inline and (pic.get('textWrap') not in {'TOP_AND_BOTTOM','SQUARE'} or pos.get('flowWithText','1')!='1'):
+ reject('pic','only flowing top-bottom or square pictures')
+ if pos.get('vertRelTo','PARA')!='PARA' or pos.get('horzRelTo','COLUMN')!='COLUMN':reject('pic','anchor reference')
+ if any(sz.get(k,'ABSOLUTE')!='ABSOLUTE' for k in ['widthRelTo','heightRelTo']):reject('pic','relative image size')
+ if pic.get('href','') or pic.get('reverse','0')!='0':reject('pic','linked or reversed image')
+ if integer(pos,'allowOverlap') or integer(pos,'horzOffset')<0 or integer(pos,'vertOffset')<0:reject('pic','overlap/negative offset')
+ if inline and any(integer(pos,k) for k in ['horzOffset','vertOffset']):reject('pic','inline offset')
+ if pos.get('horzAlign','LEFT')!='LEFT' or pos.get('vertAlign','TOP')!='TOP':reject('pic','object alignment')
+ extent=width if inline else width+outer['left']+outer['right']+integer(pos,'horzOffset')
+ bound=paper['width']-body['x']-outer['left'] if inline and paper else body['width']
+ if width<=0 or height<=0 or extent>bound or height+outer['top']+outer['bottom']>=body['height']:reject('pic','outside picture geometry')
+ for n in ['flip','rotationInfo']:
+ el=pic.find(HP+n)
+ keys={'flip':['horizontal','vertical'],'offset':['x','y'],'rotationInfo':['angle']}[n]
+ if el is not None and any(integer(el,k) for k in keys):reject('pic','transform '+n)
+ if any(image.get(k,'0')!='0' for k in ['bright','contrast','alpha']) or image.get('effect','REAL_PIC')!='REAL_PIC':reject('pic','image effects')
+ effects=pic.find(HP+'effects')
+ if effects is not None and len(effects):reject('pic','drawing effects')
+ # Axis-aligned authored resize. Translation before/after scaling may
+ # cancel (a Hancom saved picture's offset); reject a real displacement.
+ matrices={name:pic.findall('.//'+HC+name) for name in ['transMatrix','scaMatrix','rotMatrix']}
+ offset=pic.find(HP+'offset')
+ if any(len(v)>1 for v in matrices.values()):reject('pic','multiple rendering transforms')
+ values={name:[float(v[0].get('e'+str(k),'0')) for k in range(1,7)] if v else [1,0,0,0,1,0] for name,v in matrices.items()}
+ trans,scale,rotation=[values[n] for n in ['transMatrix','scaMatrix','rotMatrix']]
+ if rotation!=[1,0,0,0,1,0] or trans[0:2]!=[1,0] or trans[3:5]!=[0,1] or scale[1]!=0 or scale[3]!=0:
+ reject('pic','rotation/shear rendering transform')
+ ow,oh=integer(org,'width'),integer(org,'height')
+ if ow<=0 or oh<=0:reject('pic','invalid original size')
+ else:
+ identity=scale==[1,0,0,0,1,0]
+ resized=scale[0]>0 and scale[4]>0 and abs(scale[0]*ow-width)<=1 and abs(scale[4]*oh-height)<=1
+ if not identity and not resized:reject('pic','rendering scale disagrees with authored size')
+ if abs(trans[2]+scale[2])>1 or abs(trans[5]+scale[5])>1:
+ reject('pic','displaced rendering transform')
+ if offset is not None and (signed32(offset,'x')!=round(trans[2]) or signed32(offset,'y')!=round(trans[5])):
+ reject('pic','offset disagrees with rendering translation')
+ rect=pic.find(HP+'imgRect')
+ points=None if rect is None else [[integer(rect.find(HC+'pt'+str(i)),k) for k in ['x','y']] for i in range(4)]
+ if points!=[[0,0],[ow,0],[ow,oh],[0,oh]]:reject('pic','nonrectangular image mapping')
+ clip=pic.find(HP+'imgClip')
+ dimensions=pic.find(HP+'imgDim')
+ dw,dh=(integer(dimensions,'dimwidth'),integer(dimensions,'dimheight')) if dimensions is not None else (ow,oh)
+ crop=[integer(clip,k) for k in ['left','top','right','bottom']]
+ if clip is None or not (dw>0 and dh>0 and 0<=crop[0]=100:reject('pages','prototype limit 100 logical pages');return
+ scene['pages'].append(dict(index=len(scene['pages']),size=paper.copy(),bodyRect=body.copy(),lines=[],objects=[]))
+ page();y=0;pending=0;squares=[]
+ def prepare_beside(block,y):
+ current=[b for b in squares if b['page']==scene['pages'][-1]['index'] and b['bottom']>y]
+ if not current:return block.get('lines',block.get('anchorLines'))
+ if len(current)>1:reject('wrap','overlapping square bands not implemented');return []
+ # Restricted monometric paragraph policy: line tops are known before
+ # shaping. Multiple segments at one top preserve left-to-right order.
+ p=block['paragraphElement'];h=measure.char_height(p.findall(HP+'run')[-1].get('charPrIDRef','0'))
+ heights={measure.char_height(r.get('charPrIDRef','0')) for r in p.findall(HP+'run')}
+ if len(heights)!=1 or block['shape'].kind!='PERCENT':reject('wrap','mixed height/fixed spacing beside picture');return []
+ pitch=_pitch(block['shape'].kind,block['shape'].value,h);boxes=[];band=current[0]
+ count=max(2,len(''.join(p.itertext()))+2)
+ for row in range(count):
+ top=y+row*pitch
+ if top>=band['bottom'] or top+h<=band['top']:
+ spans=[(body['x'],body['width'])]
+ else:
+ left=(body['x'],band['left']-body['x']);right=(band['right'],body['x']+body['width']-band['right'])
+ flow=band['flow'];spans=[s for s in ([left,right] if flow=='BOTH_SIDES' else [left] if flow=='LEFT_ONLY' else [right] if flow=='RIGHT_ONLY' else [max([left,right],key=lambda s:s[1])]) if s[1]>=_MIN_SIDE]
+ if not spans:reject('wrap','no side wide enough');return []
+ boxes.extend(dict(x=x,width=w,y=row*pitch) for x,w in spans)
+ lines,_=_prepare_paragraph(p,block['paragraphId'],root,measure,body,provider,reject,lambda:bool(scene['unsupported']),line_boxes=boxes)
+ return lines or []
+ def place_row(block,r,y,repeated=False,fragment=None):
+ pg=scene['pages'][-1];h=block['rows'][r] if fragment is None else fragment['height'];x=body['x']+block['outer']['left']
+ if block.get('horizontalAlignment')=='RIGHT':x=body['x']+body['width']-block['width']-block['outer']['right']
+ anchor=block.get('paragraphAnchor',{})
+ x+=anchor.get('left',0) if block.get('horizontalAlignment')!='RIGHT' else -anchor.get('right',0)
+ if block['inline']:
+ box_x=block.get('inlineBoxX',body['x']);box_w=block.get('inlineBoxWidth',body['width']);x=box_x+block['outer']['left']
+ if block.get('inlineHorizontalAlignment')=='CENTER':x+=max(0,(box_w-block['width']-block['outer']['left']-block['outer']['right'])/2)
+ elif block.get('inlineHorizontalAlignment')=='RIGHT':x+=max(0,box_w-block['width']-block['outer']['left']-block['outer']['right'])
+ cells=[];nested=[]
+ source_cells=[c for c in block['cells'] if c['row']==r] if fragment is None else fragment['cells']
+ for c in source_cells:
+ ch=sum(block['rows'][r:r+c['rowSpan']]) if fragment is None else c.get('fragmentHeight',h)
+ cx=x+sum(block['columns'][:c['col']]);cm=c['margin'];free=ch-cm['top']-cm['bottom']-c['contentHeight']
+ dy=cm['top']+(free/2 if c['verticalAlignment']=='CENTER' else free if c['verticalAlignment']=='BOTTOM' else 0)
+ for line in c['lines']:
+ line=copy.deepcopy(line);line['x']+=cx;line['lineBoxX']+=cx
+ for g in line['glyphRuns']:g['origin'][0]+=cx
+ line['repeatedHeader']=repeated
+ positioned=position_line(line,body['y']+y+dy,paper,scene)
+ # A visible cell glyph must fit inside its cell rectangle.
+ for g in positioned['glyphRuns']:
+ if g['glyphId'] and (g['inkBounds'][0]cx+c['width'] or g['inkBounds'][3]>body['y']+y+ch):
+ scene['errors'].append('cell glyph clipping')
+ pg['lines'].append(positioned)
+ cells.append(dict(row=r,col=c['col'],rowSpan=c['rowSpan'],colSpan=c['colSpan'],rect=[cx,body['y']+y,c['width'],ch],
+ textOrigin=[cx+cm['left'],body['y']+y+dy],borders=c['borders']))
+ if 'sourceRow' in c:cells[-1]['sourceRow']=c['sourceRow']
+ if 'fill' in c:cells[-1]['fill']=c['fill']
+ nested.extend(translated_object(o,cx,body['y']+y+dy) for o in c.get('objects',[]))
+ pg['objects'].append(dict(kind='tableRow',paragraphId=block['paragraphId'],row=r,repeatedHeader=repeated,cells=cells,
+ rect=[x,body['y']+y,block['width'],h]))
+ if fragment is not None:
+ pg['objects'][-1].update(fragmentIndex=fragment['index'],continues=fragment['continues'])
+ pg['objects'].extend(nested)
+
+ def cell_row(block,r,y):
+ """Consume one CELL row using glyph-derived line continuations.
+
+ Spare-height cut/drop rules are shared with pages._flow_row. Every
+ cell retains its own unread lines; completed cells continue as empty
+ rectangles, never replaying their text. Split borders are explicit.
+ """
+ cells=copy.deepcopy([c for c in block['cells'] if c['row']==r])
+ height=block['rows'][r];index=0
+ while True:
+ foot=body['height']-block['outer']['bottom']-block['rowBreakCut']
+ available=foot-y
+ nested_height=max((c['contentHeight']+c['margin']['top']+c['margin']['bottom'] for c in cells if c.get('objects')),default=0)
+ if nested_height>available:
+ max_room=foot-block['outer']['top']-(block['rows'][0] if block['repeatHeader'] and r>0 else 0)
+ if nested_height>max_room:reject('tbl/tc','nested story requires recursive continuation');return y
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and r>0:place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ continue
+ if available<=0:
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and r>0:
+ place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if scene['unsupported']:return y
+ continue
+ if height<=available:
+ place_row(block,r,y,fragment=dict(height=height,cells=cells,index=index,continues=False))
+ return y+height
+ if any(c['rowSpan']!=1 for c in cells):
+ reject('tbl','CELL vertical-merge fragmentation not yet validated');return y
+ parts=[];rests=[];counts=[]
+ for c in cells:
+ cm=c['margin'];vertical=cm['top']+cm['bottom']
+ fitting=[]
+ for l in c['lines']:
+ if l['y']+l['height']+vertical>available:break
+ fitting.append(l)
+ counts.append(len(fitting))
+ part=copy.deepcopy(c);part['lines']=fitting
+ part['contentHeight']=max((l['y']+l['height'] for l in fitting),default=0)
+ parts.append(part)
+ rest=copy.deepcopy(c);rest['lines']=rest['lines'][len(fitting):]
+ if c.get('objects'):
+ part['contentHeight']=c['contentHeight']
+ rest.pop('objects',None)
+ if rest['lines']:
+ shift=rest['lines'][0]['y']-rest['lines'][0].get('paragraphBefore',0)
+ for l in rest['lines']:
+ l['y']-=shift;l['baseline']-=shift
+ for g in l['glyphRuns']:g['origin'][1]-=shift
+ rest['contentHeight']=max((l['y']+l['height'] for l in rest['lines']),default=0)
+ rests.append(rest)
+ if any(c['lines'] and count==0 for c,count in zip(cells,counts)):
+ # All populated cells must fit their first line, including a
+ # short neighbouring cell; otherwise move the row intact.
+ if y==0 or (block['repeatHeader'] and y==block['rows'][0]+block['outer']['top']):
+ reject('tbl','CELL first line cannot fit below repeated header');return y
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and r>0:
+ place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if scene['unsupported']:return y
+ continue
+ content_height=max((c['contentHeight']+c['margin']['top']+c['margin']['bottom'] for c in cells),default=0)
+ spare=height-content_height
+ remaining_lines=any(c['lines'] for c in rests)
+ if spare>0 or not remaining_lines:
+ drawn_height=available
+ rest_height=max(height-available,
+ max((c['contentHeight']+c['margin']['top']+c['margin']['bottom'] for c in rests if c['lines']),default=0))
+ if not remaining_lines and rest_height<=_SPARE_DROPPED:rest_height=0
+ else:
+ # A new row's authored minimum still constrains its first
+ # fragment. At the page foot it is clipped to available room;
+ # the unread glyph lines retain their own continuation height.
+ minimum=max(c['minimumHeight'] for c in cells) if index==0 else 0
+ drawn_height=min(available,max(minimum,
+ max(c['contentHeight']+c['margin']['top']+c['margin']['bottom'] for c in parts)))
+ rest_height=max(c['contentHeight']+c['margin']['top']+c['margin']['bottom'] for c in rests if c['lines'])
+ for c,rest in zip(cells,rests):
+ rest['minimumHeight']=max(0,c['minimumHeight']-drawn_height)
+ if remaining_lines:
+ rest_height=max(rest_height,max(c['minimumHeight'] for c in rests))
+ place_row(block,r,y,fragment=dict(height=drawn_height,cells=parts,index=index,continues=rest_height>0))
+ if not rest_height:return body['height']-block['outer']['bottom']
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and r>0:
+ place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if scene['unsupported']:return y
+ cells=rests;height=rest_height;index+=1
+
+ def merged_cell_group(block,first,end,y):
+ """Fragment a connected group of rows using independent cell states.
+
+ Remaining single-row cells constrain their row first. Merged cell
+ deficits then grow the last row they cover, in end-row order, as the
+ authored grid model does. Source stories never change with visible row
+ addresses, and completed cells retain their grid borders without text.
+ """
+ rows={r:block['rows'][r] for r in range(first,end)}
+ cells=copy.deepcopy([c for c in block['cells'] if first<=c['row']0 else 0
+ if height>foot-block['outer']['top']-repeat_height:
+ reject('tbl/tc','merged nested story requires recursive continuation');return y
+ if y+height>foot:
+ page();y=block['outer']['top']
+ if repeat_height:place_row(block,0,y,repeated=True);y+=repeat_height
+ for rr in range(first,end):place_row(block,rr,y);y+=block['rows'][rr]
+ return y
+ for c in cells:c['sourceRow']=c['row']
+ index=0
+ while rows:
+ foot=body['height']-block['outer']['bottom']-block['rowBreakCut']
+ tops={};top=y
+ for r,h in sorted(rows.items()):tops[r]=top;top+=h
+ total=top-y
+ if total<=foot-y:
+ draw_rows=rows;draw_cells=cells;more=False;rest_rows={};rest_cells=[]
+ else:
+ if foot<=y:
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and first>0:
+ place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if scene['unsupported']:return y
+ continue
+ cut=next(r for r in sorted(rows) if tops[r]+rows[r]>foot)
+ draw_rows={r:h for r,h in rows.items() if r=cut}
+ draw_cells=[];rest_cells=[];constraints=[];draw_constraints=[]
+ for c in cells:
+ start=c['row'];last=start+c['rowSpan']-1
+ if start>cut:
+ rest_cells.append(copy.deepcopy(c));constraints.append((start,last,c['requiredHeight']));continue
+ available=foot-tops[start];cm=c['margin'];vertical=cm['top']+cm['bottom']
+ part=copy.deepcopy(c);rest=copy.deepcopy(c)
+ if lastavailable:break
+ fitting.append(l)
+ part['lines']=fitting;part['contentHeight']=max((l['y']+l['height'] for l in fitting),default=0)
+ rest['lines']=rest['lines'][len(fitting):]
+ if rest['lines']:
+ shift=rest['lines'][0]['y']-rest['lines'][0].get('paragraphBefore',0)
+ for l in rest['lines']:
+ l['y']-=shift;l['baseline']-=shift
+ for g in l['glyphRuns']:g['origin'][1]-=shift
+ rest['contentHeight']=max((l['y']+l['height'] for l in rest['lines']),default=0)
+ if c['requiredHeight']<=available:
+ drawn=c['requiredHeight'];required=0
+ elif not fitting and c['lines']:
+ drawn=0;required=c['requiredHeight']
+ elif c['requiredHeight']>c['contentHeight']+vertical or not rest['lines']:
+ drawn=available;required=max(c['requiredHeight']-available,
+ rest['contentHeight']+vertical if rest['lines'] else 0)
+ if not rest['lines'] and required<=_SPARE_DROPPED:required=0
+ else:
+ drawn=min(available,max(c['minimumHeight'],part['contentHeight']+vertical))
+ required=max(rest['contentHeight']+vertical,c['minimumHeight']-drawn)
+ part['rowSpan']=min(last,cut)-start+1
+ draw_cells.append(part);draw_constraints.append((start,cut,drawn))
+ rest['minimumHeight']=max(0,c['minimumHeight']-drawn)
+ rest['requiredHeight']=required;rest['row']=max(start,cut);rest['rowSpan']=last-rest['row']+1
+ # Grid continuations remain even after all glyphs and
+ # authored height are consumed; they add no row constraint.
+ rest_cells.append(rest);constraints.append((rest['row'],last,required))
+ for start,last,h in draw_constraints:
+ draw_rows[last]+=max(0,h-sum(draw_rows.get(r,0) for r in range(start,last+1)))
+ cut_cells=[c for c in draw_cells if c['row']==cut]
+ if cut_cells and not any(c['lines'] for c in cut_cells) and any(r0 for r,h in draw_rows.items()):
+ # A row whose first cell line cannot fit has not begun.
+ # Continue the preceding row's border to the merged band's
+ # foot instead of inserting an empty sliver of a new row.
+ previous=max(r for r,h in draw_rows.items() if r0)
+ draw_rows[previous]+=draw_rows.pop(cut)
+ draw_cells=[c for c in draw_cells if c['row']!=cut]
+ for start,last,h in sorted(constraints,key=lambda a:(a[1]-a[0],a[1],a[0])):
+ if start==last:rest_rows[start]=max(rest_rows[start],h)
+ for start,last,h in sorted(constraints,key=lambda a:(a[1],a[0])):
+ if start0 else 0):
+ reject('tbl','merged CELL group cannot make progress');return y
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and first>0:place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if scene['unsupported']:return y
+ continue
+ # Collapse zero-height grid rows while keeping each source address.
+ visible={r:h for r,h in draw_rows.items() if h>0}
+ for c in draw_cells:
+ last=min(c['row']+c['rowSpan']-1,max(visible))
+ starts=[r for r in visible if c['row']<=r<=last]
+ if not starts:continue
+ c['row']=min(starts);c['rowSpan']=last-c['row']+1
+ c['fragmentHeight']=sum(draw_rows.get(r,0) for r in range(c['row'],last+1))
+ for r,h in sorted(visible.items()):
+ own=[c for c in draw_cells if c['row']==r and c.get('fragmentHeight',0)>0]
+ place_row(block,r,y,fragment=dict(height=h,cells=own,index=index,continues=more));y+=h
+ if not more:return y if total<=foot-tops[min(tops)] else body['height']-block['outer']['bottom']
+ cells=rest_cells;rows={r:h for r,h in rest_rows.items() if h>0}
+ normalized=[]
+ for c in cells:
+ last=c['row']+c['rowSpan']-1;starts=[r for r in rows if c['row']<=r<=last]
+ if not starts:continue
+ c['row']=min(starts);c['rowSpan']=last-c['row']+1;normalized.append(c)
+ cells=normalized
+ page();y=block['outer']['top']
+ if block['repeatHeader'] and first>0:place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if scene['unsupported']:return y
+ index+=1
+ for block in blocks:
+ shape=block['shape']
+ if block['pageBreakBefore'] and (scene['pages'][-1]['lines'] or scene['pages'][-1]['objects']):page();y=0;pending=0
+ y+=pending+shape.prev
+ if block['kind']!='body' and any(b['page']==scene['pages'][-1]['index'] and b['bottom']>y for b in squares):
+ reject('wrap','another object beside an active picture is not implemented');continue
+ if block['kind']=='body':
+ lines=prepare_beside(block,y)
+ if not lines:continue
+ height=lines[-1].get('relativeY',sum(l['pitch'] for l in lines[:-1]))+lines[-1]['height']
+ if shape.flags.get('keepLines') in {'1','true'} and y+height>=body['height'] and height=body['height'] and scene['pages'][-1]['lines']:page();y=0;base=-line.get('relativeY',0)
+ line=copy.deepcopy(line);line['story']=dict(kind='body',paragraph=line['paragraphId'])
+ scene['pages'][-1]['lines'].append(position_line(line,body['y']+y,paper,scene));y+=line['pitch']
+ elif block['kind']=='table':
+ outer=block['outer'];y+=outer['top']
+ total=sum(block['rows'])
+ if block['inline']:
+ if total>=body['height']:reject('tbl','inline table taller than body; requires floating table fragmentation');continue
+ if y+total>=body['height']:page();y=outer['top']
+ for r,h in enumerate(block['rows']):place_row(block,r,y);y+=h
+ y+=outer['bottom']+block['lineGap']
+ else:
+ if block['pageBreak']=='CELL':
+ if block['repeatHeader'] and block['rows'][0]>=body['height']-block['rowBreakCut']:
+ reject('tbl','CELL repeated header leaves no room');continue
+ r=0
+ while rr+1 else cell_row(block,r,y)
+ if scene['unsupported']:break
+ r=end
+ y+=outer['bottom'];continue
+ if any(h>body['height']-block['rowBreakCut'] for h in block['rows']):reject('tbl','row taller than page requires cell-content splitting');continue
+ r=0
+ while rbody['height']-block['rowBreakCut']:reject('tbl','merged group taller than page');break
+ if y+groupHeight>body['height']-block['rowBreakCut']:
+ page();y=0
+ if block['repeatHeader'] and r>0:
+ place_row(block,0,y,repeated=True);y+=block['rows'][0]
+ if y+groupHeight>body['height']-block['rowBreakCut']:reject('tbl','header leaves insufficient room');break
+ for rr in range(r,end):place_row(block,rr,y);y+=block['rows'][rr]
+ r=end
+ y+=outer['bottom']
+ else:
+ outer=block['outer'];h=block['height']+outer['top']+outer['bottom']
+ if y+h>=body['height']:page();y=0
+ obj={k:copy.deepcopy(v) for k,v in block.items() if k not in {'shape','pageBreakBefore','lineGap','paragraphElement','anchorLines'}}
+ x=body['x']+outer['left'];top=y+outer['top']
+ if block['inline']:
+ box_x=block.get('inlineBoxX',body['x']);box_w=block.get('inlineBoxWidth',body['width']);x=box_x+outer['left']
+ if block.get('inlineHorizontalAlignment')=='CENTER':x+=max(0,(box_w-block['width']-outer['left']-outer['right'])/2)
+ elif block.get('inlineHorizontalAlignment')=='RIGHT':x+=max(0,box_w-block['width']-outer['left']-outer['right'])
+ if not block['inline']:
+ x+=integer(block['anchor'],'horzOffset');top+=integer(block['anchor'],'vertOffset')
+ if top+block['height']+outer['bottom']>=body['height']:page();y=0;top=outer['top']
+ obj['rect']=[x,body['y']+top,block['width'],block['height']]
+ scene['pages'][-1]['objects'].append(obj)
+ if block['inline']:y+=h+block['lineGap']
+ elif block['textWrap']=='TOP_AND_BOTTOM':
+ # A flowing picture excludes its band from subsequent text;
+ # its empty anchor remains at the paragraph start and does not
+ # consume a second line after the excluded band.
+ for line in block['anchorLines']:
+ line=copy.deepcopy(line);line['story']=dict(kind='body',paragraph=block['paragraphId'])
+ scene['pages'][-1]['lines'].append(position_line(line,body['y']+y,paper,scene))
+ y=max(y+sum(l['pitch'] for l in block['anchorLines']),top+block['height']+outer['bottom'])
+ else:
+ squares.append(dict(page=scene['pages'][-1]['index'],left=x-outer['left'],right=x+block['width']+outer['right'],
+ top=top-outer['top'],bottom=top+block['height']+outer['bottom'],flow=block['textFlow']))
+ lines=prepare_beside(block,y);base=y
+ for line in lines:
+ line=copy.deepcopy(line);line['story']=dict(kind='body',paragraph=block['paragraphId'])
+ ly=base+line.get('relativeY',0);scene['pages'][-1]['lines'].append(position_line(line,body['y']+ly,paper,scene));y=ly+line['pitch']
+ pending=shape.next
diff --git a/tests/fixtures/native_preview/README.md b/tests/fixtures/native_preview/README.md
new file mode 100644
index 00000000..3cc1a2e1
--- /dev/null
+++ b/tests/fixtures/native_preview/README.md
@@ -0,0 +1,8 @@
+# Synthetic native-preview fixture
+
+`table.hwpx` is a generated 2×2 table with colored borders. Its only text is
+`색 테두리 교차 J01` and the four row/column labels. It contains no external or
+user document content. It exercises authored cell geometry and source-cache
+exclusion with a synthetic glyph provider, independently of any font library.
+
+SHA256: `f96eedf520326efe4c646c02d5da5e5505ac3df0d838008f4e588fb7735cca24`.
diff --git a/tests/fixtures/native_preview/table.hwpx b/tests/fixtures/native_preview/table.hwpx
new file mode 100644
index 00000000..19c2d9ad
Binary files /dev/null and b/tests/fixtures/native_preview/table.hwpx differ
diff --git a/tests/test_native_preview_scene.py b/tests/test_native_preview_scene.py
new file mode 100644
index 00000000..9dda4269
--- /dev/null
+++ b/tests/test_native_preview_scene.py
@@ -0,0 +1,108 @@
+"""Synthetic layout contracts; no font files, rasterizer or office application."""
+from __future__ import annotations
+
+import io
+from pathlib import Path
+
+import pytest
+
+from hwpx import HwpxDocument
+from hwpx.layout.body_scene import BodySceneSession, build_body_scene
+from hwpx.layout.object_scene import build_document_scene
+
+
+class SyntheticGlyphs:
+ """Deliberately simple metrics for semantic contracts, not visual fidelity."""
+
+ def shape(self, text, attributes, style):
+ height = attributes['height']
+ return [dict(glyphId=ord(c), cluster=i, fontFile='synthetic', faceIndex=0,
+ advance=height / 2, spacingAdvance=0, rawAdvance=height / 2,
+ offset=[0, 0], inkBounds=[0, -height, height / 2, 0],
+ height=height, ratio=100, italic=False,
+ underlinePosition=0, underlineThickness=1)
+ for i, c in enumerate(text)]
+
+ def manifest(self):
+ return {'fonts': [], 'shapingBackend': {'name': 'synthetic contract metrics'}}
+
+
+def body_document():
+ document = HwpxDocument.new()
+ document.add_paragraph('한글 scene contract ' * 5)
+ stream = io.BytesIO()
+ document.save_to_stream(stream)
+ return stream.getvalue()
+
+
+def test_table_scene_preserves_cells_and_rejects_fidelity_claim():
+ data = (Path(__file__).parent / 'fixtures/native_preview/table.hwpx').read_bytes()
+ scene = build_document_scene(data, SyntheticGlyphs())
+ assert scene['status'] == 'supported'
+ assert len(scene['pages']) == 1
+ objects = scene['pages'][0]['objects']
+ assert sum(len(obj.get('cells', [])) for obj in objects) == 4
+ assert scene['qualityValidated'] is False
+ assert scene['cacheInputReads'] == 0
+
+
+def test_source_layout_cache_cannot_change_semantics_or_geometry():
+ data = body_document()
+ scenes = [build_body_scene(data, SyntheticGlyphs(), cache_mode=mode,
+ inject_test_cache=True)
+ for mode in ['ignore', 'remove', 'poison']]
+ assert all(s['status'] == 'supported' and s['cacheInputReads'] == 0 for s in scenes)
+ assert scenes[0]['pages'] == scenes[1]['pages'] == scenes[2]['pages']
+ assert len({s['semanticSha256'] for s in scenes}) == 1
+
+
+def test_incremental_scene_is_not_mutable_cache_storage():
+ data = body_document()
+ session = BodySceneSession(SyntheticGlyphs())
+ first = session.render(data)
+ expected = build_body_scene(data, SyntheticGlyphs())
+ first['pages'].clear()
+ again = session.render(data)
+ assert again['status'] == 'supported'
+ assert again['pages'] == expected['pages']
+ assert again['incremental']['paragraphsReused'] > 0
+
+
+def test_shaping_failure_cannot_return_partial_pages():
+ class MissingFont(SyntheticGlyphs):
+ def shape(self, text, attributes, style):
+ raise ValueError('required font unavailable')
+
+ scene = build_body_scene(body_document(), MissingFont())
+ assert scene['status'] == 'unsupported'
+ assert scene['pages'] == []
+ assert any('font unavailable' in reason['reason'] for reason in scene['unsupported'])
+
+
+def test_unknown_cache_mode_is_rejected():
+ with pytest.raises(ValueError, match='cache') as caught:
+ build_body_scene(body_document(), SyntheticGlyphs(), cache_mode='reuse-source')
+ assert caught.value.code == 'preview-cache-mode-invalid'
+ assert caught.value.context == {'cacheMode': 'reuse-source'}
+
+
+def test_document_scene_uses_one_guarded_document_parse(monkeypatch):
+ data = body_document()
+ original = HwpxDocument.open
+ calls = []
+
+ def counted(*args, **kwargs):
+ calls.append(True)
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(HwpxDocument, 'open', counted)
+ assert build_document_scene(data, SyntheticGlyphs())['status'] == 'supported'
+ assert len(calls) == 1
+
+
+@pytest.mark.parametrize('capacity', [0, -1])
+def test_invalid_cache_capacity_has_a_structured_error(capacity):
+ with pytest.raises(ValueError) as caught:
+ BodySceneSession(SyntheticGlyphs(), max_paragraphs=capacity)
+ assert caught.value.code == 'preview-cache-capacity-invalid'
+ assert caught.value.context == {'maxParagraphs': capacity}
diff --git a/tests/test_product_boundary.py b/tests/test_product_boundary.py
index aa3c563b..6b923d36 100644
--- a/tests/test_product_boundary.py
+++ b/tests/test_product_boundary.py
@@ -321,7 +321,8 @@ def test_real_tree_gate_runs_from_a_gitless_source_copy(tmp_path: Path) -> None:
# +1: FormFit's glyph width table (form_fit/_glyph_table.py), read from the font files.
# +1: row and column insertion and deletion and cell splits on the table object model
# (oxml/table_structure.py), outside table.py because that owner file sits at its 1600-line cap.
- assert report["classifiedFiles"] == 182
+ # +2: injected-metrics body/document preview scenes (layout/body_scene.py and object_scene.py).
+ assert report["classifiedFiles"] == 184
def test_gitless_cli_reproduces_literal_dynamic_import_failure_without_mutating_source(