msdoc2docx 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doc2docx/__init__.py +6 -0
- doc2docx/__main__.py +4 -0
- doc2docx/binary/__init__.py +4 -0
- doc2docx/binary/reader.py +87 -0
- doc2docx/cfb/__init__.py +5 -0
- doc2docx/cfb/constants.py +9 -0
- doc2docx/cfb/container.py +370 -0
- doc2docx/cfb/directory.py +135 -0
- doc2docx/cfb/header.py +108 -0
- doc2docx/cli.py +100 -0
- doc2docx/converter.py +321 -0
- doc2docx/diagnostics.py +105 -0
- doc2docx/errors.py +37 -0
- doc2docx/model/__init__.py +57 -0
- doc2docx/model/document.py +806 -0
- doc2docx/msdoc/__init__.py +24 -0
- doc2docx/msdoc/document_properties.py +31 -0
- doc2docx/msdoc/fib.py +246 -0
- doc2docx/msdoc/fonts.py +132 -0
- doc2docx/msdoc/formatting.py +467 -0
- doc2docx/msdoc/headers.py +184 -0
- doc2docx/msdoc/pieces.py +407 -0
- doc2docx/msdoc/sections.py +259 -0
- doc2docx/msdoc/sprm.py +753 -0
- doc2docx/msdoc/styles.py +288 -0
- doc2docx/ooxml/__init__.py +3 -0
- doc2docx/ooxml/package.py +1163 -0
- msdoc2docx-0.7.0.dist-info/METADATA +84 -0
- msdoc2docx-0.7.0.dist-info/RECORD +33 -0
- msdoc2docx-0.7.0.dist-info/WHEEL +5 -0
- msdoc2docx-0.7.0.dist-info/entry_points.txt +2 -0
- msdoc2docx-0.7.0.dist-info/licenses/LICENSE +21 -0
- msdoc2docx-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""Document intermediate representation with CP-aware direct formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
from ..diagnostics import ConversionReport, SourceLocation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_MAX_TABLE_DEPTH = 64
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BreakType(StrEnum):
|
|
16
|
+
LINE = "line"
|
|
17
|
+
PAGE = "page"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SectionBreakType(StrEnum):
|
|
21
|
+
CONTINUOUS = "continuous"
|
|
22
|
+
NEXT_COLUMN = "nextColumn"
|
|
23
|
+
NEXT_PAGE = "nextPage"
|
|
24
|
+
EVEN_PAGE = "evenPage"
|
|
25
|
+
ODD_PAGE = "oddPage"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True, frozen=True)
|
|
29
|
+
class CharacterProperties:
|
|
30
|
+
"""Direct character properties; ``None`` means not specified by the DOC run."""
|
|
31
|
+
|
|
32
|
+
style_id: int | None = None
|
|
33
|
+
ascii_font: str | None = None
|
|
34
|
+
high_ansi_font: str | None = None
|
|
35
|
+
east_asia_font: str | None = None
|
|
36
|
+
complex_script_font: str | None = None
|
|
37
|
+
bold: bool | None = None
|
|
38
|
+
italic: bool | None = None
|
|
39
|
+
strike: bool | None = None
|
|
40
|
+
double_strike: bool | None = None
|
|
41
|
+
small_caps: bool | None = None
|
|
42
|
+
caps: bool | None = None
|
|
43
|
+
hidden: bool | None = None
|
|
44
|
+
underline: str | None = None
|
|
45
|
+
color: str | None = None
|
|
46
|
+
highlight: str | None = None
|
|
47
|
+
size_half_points: int | None = None
|
|
48
|
+
vertical_align: str | None = None
|
|
49
|
+
position_half_points: int | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True, frozen=True)
|
|
53
|
+
class BorderProperties:
|
|
54
|
+
style: str
|
|
55
|
+
size_eighth_points: int
|
|
56
|
+
color: str = "auto"
|
|
57
|
+
space_points: int = 0
|
|
58
|
+
shadow: bool = False
|
|
59
|
+
frame: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True, frozen=True)
|
|
63
|
+
class TableBorders:
|
|
64
|
+
top: BorderProperties | None = None
|
|
65
|
+
left: BorderProperties | None = None
|
|
66
|
+
bottom: BorderProperties | None = None
|
|
67
|
+
right: BorderProperties | None = None
|
|
68
|
+
inside_horizontal: BorderProperties | None = None
|
|
69
|
+
inside_vertical: BorderProperties | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True, frozen=True)
|
|
73
|
+
class TableCellMargins:
|
|
74
|
+
top: int | None = 0
|
|
75
|
+
left: int | None = 108
|
|
76
|
+
bottom: int | None = 0
|
|
77
|
+
right: int | None = 108
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True, frozen=True)
|
|
81
|
+
class ShadingProperties:
|
|
82
|
+
pattern: str
|
|
83
|
+
foreground: str = "auto"
|
|
84
|
+
background: str = "auto"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True, frozen=True)
|
|
88
|
+
class TableCellMarginOverride:
|
|
89
|
+
first_cell: int
|
|
90
|
+
limit_cell: int
|
|
91
|
+
sides: tuple[str, ...]
|
|
92
|
+
width_twips: int | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True, frozen=True)
|
|
96
|
+
class TableCellDefinition:
|
|
97
|
+
preferred_width_twips: int | None = None
|
|
98
|
+
horizontal_merge: str | None = None
|
|
99
|
+
vertical_merge: str | None = None
|
|
100
|
+
vertical_alignment: str | None = None
|
|
101
|
+
fit_text: bool | None = None
|
|
102
|
+
no_wrap: bool | None = None
|
|
103
|
+
borders: TableBorders = field(default_factory=TableBorders)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True, frozen=True)
|
|
107
|
+
class TableRowProperties:
|
|
108
|
+
cell_boundaries_twips: tuple[int, ...] = ()
|
|
109
|
+
cell_definitions: tuple[TableCellDefinition, ...] = ()
|
|
110
|
+
alignment: str | None = None
|
|
111
|
+
left_indent_twips: int | None = None
|
|
112
|
+
gap_half_twips: int | None = None
|
|
113
|
+
height_twips: int | None = None
|
|
114
|
+
height_rule: str | None = None
|
|
115
|
+
cant_split: bool | None = None
|
|
116
|
+
is_header: bool | None = None
|
|
117
|
+
borders: TableBorders = field(default_factory=TableBorders)
|
|
118
|
+
default_cell_margins: TableCellMargins = field(
|
|
119
|
+
default_factory=TableCellMargins
|
|
120
|
+
)
|
|
121
|
+
cell_margin_overrides: tuple[TableCellMarginOverride, ...] = ()
|
|
122
|
+
cell_shadings: tuple[ShadingProperties | None, ...] = ()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(slots=True, frozen=True)
|
|
126
|
+
class ParagraphProperties:
|
|
127
|
+
"""Direct paragraph properties represented in WordprocessingML units."""
|
|
128
|
+
|
|
129
|
+
style_id: int | None = None
|
|
130
|
+
justification: str | None = None
|
|
131
|
+
keep_lines: bool | None = None
|
|
132
|
+
keep_next: bool | None = None
|
|
133
|
+
page_break_before: bool | None = None
|
|
134
|
+
left_indent_twips: int | None = None
|
|
135
|
+
right_indent_twips: int | None = None
|
|
136
|
+
first_line_indent_twips: int | None = None
|
|
137
|
+
space_before_twips: int | None = None
|
|
138
|
+
space_after_twips: int | None = None
|
|
139
|
+
line_spacing_twips: int | None = None
|
|
140
|
+
line_rule: str | None = None
|
|
141
|
+
in_table: bool | None = None
|
|
142
|
+
table_depth: int | None = None
|
|
143
|
+
table_terminating: bool | None = None
|
|
144
|
+
inner_table_cell: bool | None = None
|
|
145
|
+
inner_table_row: bool | None = None
|
|
146
|
+
table_row: TableRowProperties | None = None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def effective_table_depth(self) -> int:
|
|
150
|
+
if self.table_depth is not None:
|
|
151
|
+
return max(self.table_depth, 0)
|
|
152
|
+
return 1 if self.in_table else 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(slots=True, frozen=True)
|
|
156
|
+
class FontDefinition:
|
|
157
|
+
"""One font from the DOC SttbfFfn table."""
|
|
158
|
+
|
|
159
|
+
index: int
|
|
160
|
+
name: str
|
|
161
|
+
alternate_name: str | None = None
|
|
162
|
+
charset: int = 0
|
|
163
|
+
family: str | None = None
|
|
164
|
+
pitch: str | None = None
|
|
165
|
+
weight: int | None = None
|
|
166
|
+
panose: bytes = b""
|
|
167
|
+
signature: bytes = b""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True, frozen=True)
|
|
171
|
+
class StyleDefinition:
|
|
172
|
+
"""A paragraph or character style, retaining its DOC style-table index."""
|
|
173
|
+
|
|
174
|
+
index: int
|
|
175
|
+
name: str
|
|
176
|
+
kind: str
|
|
177
|
+
based_on: int | None = None
|
|
178
|
+
next_style: int | None = None
|
|
179
|
+
paragraph_properties: ParagraphProperties = field(
|
|
180
|
+
default_factory=ParagraphProperties
|
|
181
|
+
)
|
|
182
|
+
character_properties: CharacterProperties = field(
|
|
183
|
+
default_factory=CharacterProperties
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def ooxml_style_id(self) -> str:
|
|
188
|
+
return f"DocStyle{self.index}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(slots=True, frozen=True)
|
|
192
|
+
class StyleSheet:
|
|
193
|
+
"""Parsed style definitions plus resolved properties used for toggles."""
|
|
194
|
+
|
|
195
|
+
styles: tuple[StyleDefinition | None, ...] = ()
|
|
196
|
+
default_character_properties: CharacterProperties = field(
|
|
197
|
+
default_factory=CharacterProperties
|
|
198
|
+
)
|
|
199
|
+
effective_character_properties: tuple[CharacterProperties | None, ...] = ()
|
|
200
|
+
|
|
201
|
+
def style_at(self, index: int | None) -> StyleDefinition | None:
|
|
202
|
+
if index is None or index < 0 or index >= len(self.styles):
|
|
203
|
+
return None
|
|
204
|
+
return self.styles[index]
|
|
205
|
+
|
|
206
|
+
def effective_character_at(self, index: int | None) -> CharacterProperties:
|
|
207
|
+
if (
|
|
208
|
+
index is not None
|
|
209
|
+
and 0 <= index < len(self.effective_character_properties)
|
|
210
|
+
and self.effective_character_properties[index] is not None
|
|
211
|
+
):
|
|
212
|
+
value = self.effective_character_properties[index]
|
|
213
|
+
assert value is not None
|
|
214
|
+
return value
|
|
215
|
+
return self.default_character_properties
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(slots=True, frozen=True)
|
|
219
|
+
class StoryCharacter:
|
|
220
|
+
"""One decoded character and its half-open MS-DOC CP range."""
|
|
221
|
+
|
|
222
|
+
text: str
|
|
223
|
+
cp_start: int
|
|
224
|
+
cp_end: int
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(slots=True, frozen=True)
|
|
228
|
+
class TextRun:
|
|
229
|
+
text: str
|
|
230
|
+
properties: CharacterProperties = field(default_factory=CharacterProperties)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(slots=True, frozen=True)
|
|
234
|
+
class Tab:
|
|
235
|
+
properties: CharacterProperties = field(default_factory=CharacterProperties)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(slots=True, frozen=True)
|
|
239
|
+
class Break:
|
|
240
|
+
kind: BreakType
|
|
241
|
+
properties: CharacterProperties = field(default_factory=CharacterProperties)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
Inline = TextRun | Tab | Break
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(slots=True, frozen=True)
|
|
248
|
+
class SectionProperties:
|
|
249
|
+
"""Resolved page layout for one half-open main-story CP range."""
|
|
250
|
+
|
|
251
|
+
cp_start: int
|
|
252
|
+
cp_end: int
|
|
253
|
+
break_type: SectionBreakType = SectionBreakType.NEXT_PAGE
|
|
254
|
+
page_width_twips: int = 12240
|
|
255
|
+
page_height_twips: int = 15840
|
|
256
|
+
orientation: str = "portrait"
|
|
257
|
+
margin_top_twips: int = 1440
|
|
258
|
+
margin_right_twips: int = 1440
|
|
259
|
+
margin_bottom_twips: int = 1440
|
|
260
|
+
margin_left_twips: int = 1440
|
|
261
|
+
header_distance_twips: int = 720
|
|
262
|
+
footer_distance_twips: int = 720
|
|
263
|
+
gutter_twips: int = 0
|
|
264
|
+
title_page: bool = False
|
|
265
|
+
even_header: HeaderFooterStory | None = None
|
|
266
|
+
default_header: HeaderFooterStory | None = None
|
|
267
|
+
even_footer: HeaderFooterStory | None = None
|
|
268
|
+
default_footer: HeaderFooterStory | None = None
|
|
269
|
+
first_header: HeaderFooterStory | None = None
|
|
270
|
+
first_footer: HeaderFooterStory | None = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(slots=True, frozen=True)
|
|
274
|
+
class Paragraph:
|
|
275
|
+
inlines: tuple[Inline, ...]
|
|
276
|
+
properties: ParagraphProperties = field(default_factory=ParagraphProperties)
|
|
277
|
+
section_end: SectionProperties | None = None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(slots=True, frozen=True)
|
|
281
|
+
class TableCell:
|
|
282
|
+
paragraphs: tuple[Paragraph, ...]
|
|
283
|
+
width_twips: int | None = None
|
|
284
|
+
grid_span: int = 1
|
|
285
|
+
vertical_merge: str | None = None
|
|
286
|
+
vertical_alignment: str | None = None
|
|
287
|
+
fit_text: bool | None = None
|
|
288
|
+
no_wrap: bool | None = None
|
|
289
|
+
borders: TableBorders = field(default_factory=TableBorders)
|
|
290
|
+
margins: TableCellMargins = field(default_factory=TableCellMargins)
|
|
291
|
+
shading: ShadingProperties | None = None
|
|
292
|
+
blocks: tuple[Paragraph | Table, ...] = ()
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def body_blocks(self) -> tuple[Paragraph | Table, ...]:
|
|
296
|
+
return self.blocks or self.paragraphs
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass(slots=True, frozen=True)
|
|
300
|
+
class TableRow:
|
|
301
|
+
cells: tuple[TableCell, ...]
|
|
302
|
+
properties: TableRowProperties = field(default_factory=TableRowProperties)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(slots=True, frozen=True)
|
|
306
|
+
class Table:
|
|
307
|
+
rows: tuple[TableRow, ...]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
Block = Paragraph | Table
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(slots=True, frozen=True)
|
|
314
|
+
class HeaderFooterStory:
|
|
315
|
+
"""One non-empty header/footer story after removing its guard paragraph."""
|
|
316
|
+
|
|
317
|
+
cp_start: int
|
|
318
|
+
cp_end: int
|
|
319
|
+
paragraphs: tuple[Paragraph, ...]
|
|
320
|
+
blocks: tuple[Block, ...] = ()
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def body_blocks(self) -> tuple[Block, ...]:
|
|
324
|
+
return self.blocks or self.paragraphs
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(slots=True, frozen=True)
|
|
328
|
+
class Document:
|
|
329
|
+
paragraphs: tuple[Paragraph, ...]
|
|
330
|
+
fonts: tuple[FontDefinition, ...] = ()
|
|
331
|
+
styles: StyleSheet = field(default_factory=StyleSheet)
|
|
332
|
+
blocks: tuple[Block, ...] = ()
|
|
333
|
+
sections: tuple[SectionProperties, ...] = ()
|
|
334
|
+
even_and_odd_headers: bool = False
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def body_blocks(self) -> tuple[Block, ...]:
|
|
338
|
+
return self.blocks or self.paragraphs
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(slots=True, frozen=True)
|
|
342
|
+
class _TableMarker:
|
|
343
|
+
kind: str
|
|
344
|
+
properties: ParagraphProperties
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(slots=True)
|
|
348
|
+
class _TableContext:
|
|
349
|
+
depth: int
|
|
350
|
+
rows: list[TableRow] = field(default_factory=list)
|
|
351
|
+
raw_cells: list[tuple[Block, ...]] = field(default_factory=list)
|
|
352
|
+
cell_blocks: list[Block] = field(default_factory=list)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _replace_margin_sides(
|
|
356
|
+
margins: TableCellMargins,
|
|
357
|
+
sides: tuple[str, ...],
|
|
358
|
+
value: int | None,
|
|
359
|
+
) -> TableCellMargins:
|
|
360
|
+
changes = {side: value for side in sides}
|
|
361
|
+
return replace(margins, **changes)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _cell_margins(
|
|
365
|
+
properties: TableRowProperties,
|
|
366
|
+
cell_index: int,
|
|
367
|
+
) -> TableCellMargins:
|
|
368
|
+
defaults = properties.default_cell_margins
|
|
369
|
+
margins = defaults
|
|
370
|
+
for override in properties.cell_margin_overrides:
|
|
371
|
+
if override.first_cell <= cell_index < override.limit_cell:
|
|
372
|
+
for side in override.sides:
|
|
373
|
+
value = override.width_twips
|
|
374
|
+
if value is None:
|
|
375
|
+
value = getattr(defaults, side)
|
|
376
|
+
margins = _replace_margin_sides(margins, (side,), value)
|
|
377
|
+
return margins
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _build_table_row(
|
|
381
|
+
raw_cells: list[tuple[Block, ...]],
|
|
382
|
+
properties: TableRowProperties,
|
|
383
|
+
report: ConversionReport,
|
|
384
|
+
) -> TableRow:
|
|
385
|
+
definitions = properties.cell_definitions
|
|
386
|
+
boundaries = properties.cell_boundaries_twips
|
|
387
|
+
if definitions and len(definitions) != len(raw_cells):
|
|
388
|
+
report.warning(
|
|
389
|
+
"TABLE_CELL_DEFINITION_MISMATCH",
|
|
390
|
+
"table row cell markers do not match its TDefTable definitions",
|
|
391
|
+
cell_count=len(raw_cells),
|
|
392
|
+
definition_count=len(definitions),
|
|
393
|
+
)
|
|
394
|
+
elif boundaries and len(boundaries) != len(raw_cells) + 1:
|
|
395
|
+
report.warning(
|
|
396
|
+
"TABLE_GRID_MISMATCH",
|
|
397
|
+
"table row cell markers do not match its column boundaries",
|
|
398
|
+
cell_count=len(raw_cells),
|
|
399
|
+
boundary_count=len(boundaries),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
cells: list[TableCell] = []
|
|
403
|
+
for index, cell_blocks in enumerate(raw_cells):
|
|
404
|
+
definition = (
|
|
405
|
+
definitions[index] if index < len(definitions) else TableCellDefinition()
|
|
406
|
+
)
|
|
407
|
+
grid_width = (
|
|
408
|
+
boundaries[index + 1] - boundaries[index]
|
|
409
|
+
if index + 1 < len(boundaries)
|
|
410
|
+
else None
|
|
411
|
+
)
|
|
412
|
+
width = definition.preferred_width_twips
|
|
413
|
+
if width is None and grid_width is not None and grid_width >= 0:
|
|
414
|
+
width = grid_width
|
|
415
|
+
content_blocks = cell_blocks or (Paragraph(()),)
|
|
416
|
+
cell = TableCell(
|
|
417
|
+
paragraphs=tuple(
|
|
418
|
+
block for block in content_blocks if isinstance(block, Paragraph)
|
|
419
|
+
),
|
|
420
|
+
width_twips=width,
|
|
421
|
+
vertical_merge=definition.vertical_merge,
|
|
422
|
+
vertical_alignment=definition.vertical_alignment,
|
|
423
|
+
fit_text=definition.fit_text,
|
|
424
|
+
no_wrap=definition.no_wrap,
|
|
425
|
+
borders=definition.borders,
|
|
426
|
+
margins=_cell_margins(properties, index),
|
|
427
|
+
shading=(
|
|
428
|
+
properties.cell_shadings[index]
|
|
429
|
+
if index < len(properties.cell_shadings)
|
|
430
|
+
else None
|
|
431
|
+
),
|
|
432
|
+
blocks=content_blocks,
|
|
433
|
+
)
|
|
434
|
+
if definition.horizontal_merge == "continue" and cells:
|
|
435
|
+
previous = cells[-1]
|
|
436
|
+
merged_width = (
|
|
437
|
+
(previous.width_twips or 0) + (cell.width_twips or 0)
|
|
438
|
+
if previous.width_twips is not None or cell.width_twips is not None
|
|
439
|
+
else None
|
|
440
|
+
)
|
|
441
|
+
cells[-1] = replace(
|
|
442
|
+
previous,
|
|
443
|
+
width_twips=merged_width,
|
|
444
|
+
grid_span=previous.grid_span + 1,
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
cells.append(cell)
|
|
448
|
+
return TableRow(tuple(cells), properties)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _assemble_table_blocks(
|
|
452
|
+
flow: list[Paragraph | _TableMarker],
|
|
453
|
+
report: ConversionReport,
|
|
454
|
+
) -> tuple[Block, ...]:
|
|
455
|
+
blocks: list[Block] = []
|
|
456
|
+
contexts: list[_TableContext] = []
|
|
457
|
+
malformed_groups = 0
|
|
458
|
+
limited_depth_items = 0
|
|
459
|
+
|
|
460
|
+
def emit_completed_context(context: _TableContext) -> None:
|
|
461
|
+
nonlocal malformed_groups
|
|
462
|
+
completed: list[Block] = []
|
|
463
|
+
if context.rows:
|
|
464
|
+
completed.append(Table(tuple(context.rows)))
|
|
465
|
+
if context.raw_cells or context.cell_blocks:
|
|
466
|
+
malformed_groups += 1
|
|
467
|
+
for cell_blocks in context.raw_cells:
|
|
468
|
+
completed.extend(cell_blocks)
|
|
469
|
+
completed.extend(context.cell_blocks)
|
|
470
|
+
if contexts:
|
|
471
|
+
contexts[-1].cell_blocks.extend(completed)
|
|
472
|
+
else:
|
|
473
|
+
blocks.extend(completed)
|
|
474
|
+
|
|
475
|
+
def close_deeper_than(depth: int) -> None:
|
|
476
|
+
while len(contexts) > depth:
|
|
477
|
+
emit_completed_context(contexts.pop())
|
|
478
|
+
|
|
479
|
+
def context_at(depth: int) -> _TableContext:
|
|
480
|
+
nonlocal malformed_groups
|
|
481
|
+
if depth > len(contexts) + 1:
|
|
482
|
+
malformed_groups += 1
|
|
483
|
+
while len(contexts) < depth:
|
|
484
|
+
contexts.append(_TableContext(len(contexts) + 1))
|
|
485
|
+
return contexts[depth - 1]
|
|
486
|
+
|
|
487
|
+
for item in flow:
|
|
488
|
+
raw_depth = item.properties.effective_table_depth
|
|
489
|
+
depth = min(raw_depth, _MAX_TABLE_DEPTH)
|
|
490
|
+
if raw_depth > _MAX_TABLE_DEPTH:
|
|
491
|
+
limited_depth_items += 1
|
|
492
|
+
if isinstance(item, Paragraph):
|
|
493
|
+
close_deeper_than(depth)
|
|
494
|
+
if depth == 0:
|
|
495
|
+
close_deeper_than(0)
|
|
496
|
+
blocks.append(item)
|
|
497
|
+
else:
|
|
498
|
+
context_at(depth).cell_blocks.append(item)
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
if depth == 0:
|
|
502
|
+
continue
|
|
503
|
+
close_deeper_than(depth)
|
|
504
|
+
context = context_at(depth)
|
|
505
|
+
if item.kind == "cell":
|
|
506
|
+
context.raw_cells.append(
|
|
507
|
+
tuple(context.cell_blocks) or (Paragraph(()),)
|
|
508
|
+
)
|
|
509
|
+
context.cell_blocks.clear()
|
|
510
|
+
elif item.kind == "row":
|
|
511
|
+
if context.cell_blocks:
|
|
512
|
+
context.raw_cells.append(tuple(context.cell_blocks))
|
|
513
|
+
context.cell_blocks.clear()
|
|
514
|
+
if not context.raw_cells:
|
|
515
|
+
malformed_groups += 1
|
|
516
|
+
continue
|
|
517
|
+
row_properties = item.properties.table_row or TableRowProperties()
|
|
518
|
+
context.rows.append(
|
|
519
|
+
_build_table_row(context.raw_cells, row_properties, report)
|
|
520
|
+
)
|
|
521
|
+
context.raw_cells.clear()
|
|
522
|
+
|
|
523
|
+
close_deeper_than(0)
|
|
524
|
+
if limited_depth_items:
|
|
525
|
+
report.warning(
|
|
526
|
+
"TABLE_DEPTH_LIMITED",
|
|
527
|
+
"table nesting exceeded the safe reconstruction depth",
|
|
528
|
+
maximum_depth=_MAX_TABLE_DEPTH,
|
|
529
|
+
item_count=limited_depth_items,
|
|
530
|
+
)
|
|
531
|
+
if malformed_groups:
|
|
532
|
+
report.warning(
|
|
533
|
+
"MALFORMED_TABLE_FLATTENED",
|
|
534
|
+
"incomplete table marker sequences were preserved as paragraphs",
|
|
535
|
+
group_count=malformed_groups,
|
|
536
|
+
)
|
|
537
|
+
return tuple(blocks)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _is_xml_character(character: str) -> bool:
|
|
541
|
+
value = ord(character)
|
|
542
|
+
return (
|
|
543
|
+
value in (0x09, 0x0A, 0x0D)
|
|
544
|
+
or 0x20 <= value <= 0xD7FF
|
|
545
|
+
or 0xE000 <= value <= 0xFFFD
|
|
546
|
+
or 0x10000 <= value <= 0x10FFFF
|
|
547
|
+
) and value not in (0xFFFE, 0xFFFF)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def parse_main_story(
|
|
551
|
+
text: str | Sequence[StoryCharacter],
|
|
552
|
+
report: ConversionReport,
|
|
553
|
+
*,
|
|
554
|
+
character_properties_at: Callable[[int], CharacterProperties] | None = None,
|
|
555
|
+
paragraph_properties_at: Callable[[int], ParagraphProperties] | None = None,
|
|
556
|
+
sections: Sequence[SectionProperties] = (),
|
|
557
|
+
story_name: str = "main",
|
|
558
|
+
) -> Document:
|
|
559
|
+
if isinstance(text, str):
|
|
560
|
+
characters = tuple(
|
|
561
|
+
StoryCharacter(character, cp, cp + 1)
|
|
562
|
+
for cp, character in enumerate(text)
|
|
563
|
+
)
|
|
564
|
+
else:
|
|
565
|
+
characters = tuple(text)
|
|
566
|
+
|
|
567
|
+
default_character_properties = CharacterProperties()
|
|
568
|
+
default_paragraph_properties = ParagraphProperties()
|
|
569
|
+
|
|
570
|
+
paragraphs: list[Paragraph] = []
|
|
571
|
+
flow: list[Paragraph | _TableMarker] = []
|
|
572
|
+
inlines: list[Inline] = []
|
|
573
|
+
text_buffer: list[str] = []
|
|
574
|
+
text_properties = default_character_properties
|
|
575
|
+
unsupported_controls: dict[int, int] = {}
|
|
576
|
+
deferred_markers: dict[str, int] = {}
|
|
577
|
+
flattened_fields = 0
|
|
578
|
+
field_stack: list[bool] = [] # False=instruction, True=result
|
|
579
|
+
last_was_terminator = False
|
|
580
|
+
section_values = tuple(sections)
|
|
581
|
+
internal_sections_by_end = {
|
|
582
|
+
section.cp_end: section for section in section_values[:-1]
|
|
583
|
+
}
|
|
584
|
+
matched_section_ends: set[int] = set()
|
|
585
|
+
|
|
586
|
+
def visible() -> bool:
|
|
587
|
+
return all(field_stack)
|
|
588
|
+
|
|
589
|
+
def flush_text() -> None:
|
|
590
|
+
if text_buffer:
|
|
591
|
+
buffered_text = "".join(text_buffer)
|
|
592
|
+
if (
|
|
593
|
+
inlines
|
|
594
|
+
and isinstance(inlines[-1], TextRun)
|
|
595
|
+
and inlines[-1].properties == text_properties
|
|
596
|
+
):
|
|
597
|
+
previous = inlines[-1]
|
|
598
|
+
inlines[-1] = TextRun(
|
|
599
|
+
previous.text + buffered_text,
|
|
600
|
+
previous.properties,
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
inlines.append(TextRun(buffered_text, text_properties))
|
|
604
|
+
text_buffer.clear()
|
|
605
|
+
|
|
606
|
+
def append_text(character: str, properties: CharacterProperties) -> None:
|
|
607
|
+
nonlocal text_properties
|
|
608
|
+
if text_buffer and properties != text_properties:
|
|
609
|
+
flush_text()
|
|
610
|
+
text_properties = properties
|
|
611
|
+
text_buffer.append(character)
|
|
612
|
+
|
|
613
|
+
def finish_paragraph(
|
|
614
|
+
properties: ParagraphProperties,
|
|
615
|
+
section_end: SectionProperties | None = None,
|
|
616
|
+
) -> None:
|
|
617
|
+
flush_text()
|
|
618
|
+
paragraph = Paragraph(tuple(inlines), properties, section_end)
|
|
619
|
+
paragraphs.append(paragraph)
|
|
620
|
+
flow.append(paragraph)
|
|
621
|
+
inlines.clear()
|
|
622
|
+
|
|
623
|
+
for unit in characters:
|
|
624
|
+
character = unit.text
|
|
625
|
+
cp_offset = unit.cp_start
|
|
626
|
+
value = ord(character)
|
|
627
|
+
character_properties = (
|
|
628
|
+
character_properties_at(cp_offset)
|
|
629
|
+
if character_properties_at is not None
|
|
630
|
+
else default_character_properties
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
if value == 0x13: # field begin
|
|
634
|
+
flush_text()
|
|
635
|
+
field_stack.append(False)
|
|
636
|
+
flattened_fields += 1
|
|
637
|
+
continue
|
|
638
|
+
if value == 0x14 and field_stack: # field separator
|
|
639
|
+
flush_text()
|
|
640
|
+
field_stack[-1] = True
|
|
641
|
+
continue
|
|
642
|
+
if value == 0x15 and field_stack: # field end
|
|
643
|
+
flush_text()
|
|
644
|
+
field_stack.pop()
|
|
645
|
+
continue
|
|
646
|
+
if not visible():
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
if character == "\r":
|
|
650
|
+
paragraph_properties = (
|
|
651
|
+
paragraph_properties_at(cp_offset)
|
|
652
|
+
if paragraph_properties_at is not None
|
|
653
|
+
else default_paragraph_properties
|
|
654
|
+
)
|
|
655
|
+
if paragraph_properties.inner_table_row:
|
|
656
|
+
if text_buffer or inlines:
|
|
657
|
+
finish_paragraph(paragraph_properties)
|
|
658
|
+
flow.append(_TableMarker("row", paragraph_properties))
|
|
659
|
+
elif paragraph_properties.inner_table_cell:
|
|
660
|
+
finish_paragraph(paragraph_properties)
|
|
661
|
+
flow.append(_TableMarker("cell", paragraph_properties))
|
|
662
|
+
else:
|
|
663
|
+
finish_paragraph(paragraph_properties)
|
|
664
|
+
last_was_terminator = True
|
|
665
|
+
elif character == "\t":
|
|
666
|
+
flush_text()
|
|
667
|
+
inlines.append(Tab(character_properties))
|
|
668
|
+
last_was_terminator = False
|
|
669
|
+
elif character in ("\n", "\v"):
|
|
670
|
+
flush_text()
|
|
671
|
+
inlines.append(Break(BreakType.LINE, character_properties))
|
|
672
|
+
last_was_terminator = False
|
|
673
|
+
elif character == "\f":
|
|
674
|
+
section = internal_sections_by_end.get(unit.cp_end)
|
|
675
|
+
if section is not None:
|
|
676
|
+
paragraph_properties = (
|
|
677
|
+
paragraph_properties_at(cp_offset)
|
|
678
|
+
if paragraph_properties_at is not None
|
|
679
|
+
else default_paragraph_properties
|
|
680
|
+
)
|
|
681
|
+
finish_paragraph(paragraph_properties, section)
|
|
682
|
+
matched_section_ends.add(section.cp_end)
|
|
683
|
+
last_was_terminator = True
|
|
684
|
+
else:
|
|
685
|
+
flush_text()
|
|
686
|
+
inlines.append(Break(BreakType.PAGE, character_properties))
|
|
687
|
+
if not section_values:
|
|
688
|
+
deferred_markers["BREAK_KIND_APPROXIMATED"] = (
|
|
689
|
+
deferred_markers.get("BREAK_KIND_APPROXIMATED", 0) + 1
|
|
690
|
+
)
|
|
691
|
+
last_was_terminator = False
|
|
692
|
+
elif value == 0x07:
|
|
693
|
+
paragraph_properties = (
|
|
694
|
+
paragraph_properties_at(cp_offset)
|
|
695
|
+
if paragraph_properties_at is not None
|
|
696
|
+
else default_paragraph_properties
|
|
697
|
+
)
|
|
698
|
+
if paragraph_properties.effective_table_depth:
|
|
699
|
+
if paragraph_properties.table_terminating:
|
|
700
|
+
if text_buffer or inlines:
|
|
701
|
+
finish_paragraph(paragraph_properties)
|
|
702
|
+
flow.append(_TableMarker("row", paragraph_properties))
|
|
703
|
+
else:
|
|
704
|
+
finish_paragraph(paragraph_properties)
|
|
705
|
+
flow.append(_TableMarker("cell", paragraph_properties))
|
|
706
|
+
last_was_terminator = True
|
|
707
|
+
else:
|
|
708
|
+
deferred_markers["TABLE_MARKER_DEFERRED"] = (
|
|
709
|
+
deferred_markers.get("TABLE_MARKER_DEFERRED", 0) + 1
|
|
710
|
+
)
|
|
711
|
+
append_text("\uFFFC", character_properties)
|
|
712
|
+
last_was_terminator = False
|
|
713
|
+
elif value in (0x01, 0x02, 0x08):
|
|
714
|
+
marker_code = {
|
|
715
|
+
0x01: "OBJECT_ANCHOR_DEFERRED",
|
|
716
|
+
0x02: "NOTE_REFERENCE_DEFERRED",
|
|
717
|
+
0x08: "OBJECT_ANCHOR_DEFERRED",
|
|
718
|
+
}[value]
|
|
719
|
+
deferred_markers[marker_code] = deferred_markers.get(marker_code, 0) + 1
|
|
720
|
+
append_text("\uFFFC", character_properties)
|
|
721
|
+
last_was_terminator = False
|
|
722
|
+
elif value < 0x20 or not _is_xml_character(character):
|
|
723
|
+
unsupported_controls[value] = unsupported_controls.get(value, 0) + 1
|
|
724
|
+
append_text("\uFFFD", character_properties)
|
|
725
|
+
last_was_terminator = False
|
|
726
|
+
else:
|
|
727
|
+
append_text(character, character_properties)
|
|
728
|
+
last_was_terminator = False
|
|
729
|
+
|
|
730
|
+
if (
|
|
731
|
+
text_buffer
|
|
732
|
+
or inlines
|
|
733
|
+
or not flow
|
|
734
|
+
or not last_was_terminator
|
|
735
|
+
):
|
|
736
|
+
paragraph_cp = characters[-1].cp_start if characters else 0
|
|
737
|
+
paragraph_properties = (
|
|
738
|
+
paragraph_properties_at(paragraph_cp)
|
|
739
|
+
if paragraph_properties_at is not None
|
|
740
|
+
else default_paragraph_properties
|
|
741
|
+
)
|
|
742
|
+
finish_paragraph(paragraph_properties)
|
|
743
|
+
|
|
744
|
+
if field_stack:
|
|
745
|
+
report.warning(
|
|
746
|
+
"UNTERMINATED_FIELD",
|
|
747
|
+
f"{story_name} story contains an unterminated field; its instruction was hidden",
|
|
748
|
+
location=SourceLocation(
|
|
749
|
+
story=story_name,
|
|
750
|
+
cp_start=characters[0].cp_start if characters else 0,
|
|
751
|
+
cp_end=characters[-1].cp_end if characters else 0,
|
|
752
|
+
),
|
|
753
|
+
open_field_count=len(field_stack),
|
|
754
|
+
)
|
|
755
|
+
if flattened_fields:
|
|
756
|
+
report.warning(
|
|
757
|
+
"FIELDS_FLATTENED",
|
|
758
|
+
"field structures were flattened to their displayed result",
|
|
759
|
+
location=SourceLocation(story=story_name),
|
|
760
|
+
field_count=flattened_fields,
|
|
761
|
+
)
|
|
762
|
+
unmatched_section_ends = sorted(
|
|
763
|
+
set(internal_sections_by_end) - matched_section_ends
|
|
764
|
+
)
|
|
765
|
+
if unmatched_section_ends:
|
|
766
|
+
report.warning(
|
|
767
|
+
"SECTION_BOUNDARY_NOT_FOUND",
|
|
768
|
+
"some PlcfSed section boundaries did not end at a section-mark character",
|
|
769
|
+
location=SourceLocation(story=story_name),
|
|
770
|
+
cp_ends=unmatched_section_ends,
|
|
771
|
+
)
|
|
772
|
+
for codepoint, count in sorted(unsupported_controls.items()):
|
|
773
|
+
report.warning(
|
|
774
|
+
"UNSUPPORTED_CONTROL_CHARACTER",
|
|
775
|
+
f"unsupported control character U+{codepoint:04X} was replaced",
|
|
776
|
+
location=SourceLocation(story=story_name),
|
|
777
|
+
count=count,
|
|
778
|
+
)
|
|
779
|
+
deferred_messages = {
|
|
780
|
+
"BREAK_KIND_APPROXIMATED": (
|
|
781
|
+
"an ambiguous page/section marker was emitted as a page break because "
|
|
782
|
+
"no matching section table entry was available"
|
|
783
|
+
),
|
|
784
|
+
"OBJECT_ANCHOR_DEFERRED": (
|
|
785
|
+
"picture or object anchor was emitted as an object replacement character"
|
|
786
|
+
),
|
|
787
|
+
"NOTE_REFERENCE_DEFERRED": (
|
|
788
|
+
"note reference was emitted as an object replacement character"
|
|
789
|
+
),
|
|
790
|
+
"TABLE_MARKER_DEFERRED": (
|
|
791
|
+
"table marker was emitted as an object replacement character"
|
|
792
|
+
),
|
|
793
|
+
}
|
|
794
|
+
for code, count in sorted(deferred_markers.items()):
|
|
795
|
+
report.warning(
|
|
796
|
+
code,
|
|
797
|
+
deferred_messages[code],
|
|
798
|
+
location=SourceLocation(story=story_name),
|
|
799
|
+
count=count,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
return Document(
|
|
803
|
+
tuple(paragraphs),
|
|
804
|
+
blocks=_assemble_table_blocks(flow, report),
|
|
805
|
+
sections=section_values,
|
|
806
|
+
)
|