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.
@@ -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
+ )