From e29dbbe1d979cc12b3fbe067fe39242faba3934c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 15 Oct 2026 12:01:20 +0900 Subject: [PATCH 1/2] feat(layout): add experimental cache-independent preview scenes --- README.md | 6 + docs/coverage-ledger.json | 6 +- docs/coverage-ledger.md | 9 +- docs/native-preview-scenes.md | 28 + pyproject.toml | 4 + src/hwpx/layout/body_scene.py | 315 +++++++++ src/hwpx/layout/object_scene.py | 797 +++++++++++++++++++++++ tests/fixtures/native_preview/README.md | 8 + tests/fixtures/native_preview/table.hwpx | Bin 0 -> 8254 bytes tests/test_native_preview_scene.py | 98 +++ 10 files changed, 1263 insertions(+), 8 deletions(-) create mode 100644 docs/native-preview-scenes.md create mode 100644 src/hwpx/layout/body_scene.py create mode 100644 src/hwpx/layout/object_scene.py create mode 100644 tests/fixtures/native_preview/README.md create mode 100644 tests/fixtures/native_preview/table.hwpx create mode 100644 tests/test_native_preview_scene.py 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/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/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..913470af --- /dev/null +++ b/src/hwpx/layout/body_scene.py @@ -0,0 +1,315 @@ +"""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 .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 ValueError('max_paragraphs must be positive') + 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 ValueError('unknown cache test mode') + 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 0000000000000000000000000000000000000000..19c2d9ad35f8efa824de2331729b92c98c27d425 GIT binary patch literal 8254 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 i##8zDgV9U&7slVxq9}ua_1@PyYui#oIXm literal 0 HcmV?d00001 diff --git a/tests/test_native_preview_scene.py b/tests/test_native_preview_scene.py new file mode 100644 index 00000000..ec852173 --- /dev/null +++ b/tests/test_native_preview_scene.py @@ -0,0 +1,98 @@ +"""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'): + build_body_scene(body_document(), SyntheticGlyphs(), cache_mode='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 From fd89cd8f8e355cf1ebf8a3d10e15261802d57c2f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 15 Oct 2026 12:23:06 +0900 Subject: [PATCH 2/2] fix(layout): register preview scene ownership and structured input errors --- docs/architecture/module-ownership.json | 12 ++++++++++++ docs/python-example-ledger.json | 2 +- src/hwpx/layout/body_scene.py | 8 ++++++-- tests/test_native_preview_scene.py | 12 +++++++++++- tests/test_product_boundary.py | 3 ++- 5 files changed, 32 insertions(+), 5 deletions(-) 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/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/src/hwpx/layout/body_scene.py b/src/hwpx/layout/body_scene.py index 913470af..3e5aa086 100644 --- a/src/hwpx/layout/body_scene.py +++ b/src/hwpx/layout/body_scene.py @@ -11,6 +11,7 @@ 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 @@ -33,7 +34,8 @@ class BodySceneSession: """ def __init__(self, provider: GlyphProvider, *, max_paragraphs=256): if max_paragraphs < 1: - raise ValueError('max_paragraphs must be positive') + 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() @@ -76,7 +78,9 @@ def _put(self, key, value): 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 ValueError('unknown cache test mode') + 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: diff --git a/tests/test_native_preview_scene.py b/tests/test_native_preview_scene.py index ec852173..9dda4269 100644 --- a/tests/test_native_preview_scene.py +++ b/tests/test_native_preview_scene.py @@ -80,8 +80,10 @@ def shape(self, text, attributes, style): def test_unknown_cache_mode_is_rejected(): - with pytest.raises(ValueError, match='cache'): + 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): @@ -96,3 +98,11 @@ def counted(*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(