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,467 @@
1
+ """CHPX/PAPX FKP parsing and physical-FC to logical-CP mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_right
6
+ from dataclasses import dataclass
7
+ import struct
8
+
9
+ from ..diagnostics import ConversionReport
10
+ from ..errors import InvalidWordDocument
11
+ from ..model import CharacterProperties, FontDefinition, ParagraphProperties, StyleSheet
12
+ from .pieces import PieceTable
13
+ from .sprm import (
14
+ apply_character_modifiers,
15
+ apply_paragraph_modifiers,
16
+ parse_grpprl,
17
+ )
18
+
19
+
20
+ FKP_SIZE = 512
21
+
22
+
23
+ @dataclass(slots=True, frozen=True)
24
+ class CharacterFormatSpan:
25
+ cp_start: int
26
+ cp_end: int
27
+ properties: CharacterProperties
28
+
29
+
30
+ @dataclass(slots=True, frozen=True)
31
+ class ParagraphFormatSpan:
32
+ cp_start: int
33
+ cp_end: int
34
+ properties: ParagraphProperties
35
+
36
+
37
+ @dataclass(slots=True, frozen=True)
38
+ class FormattingMap:
39
+ character_spans: tuple[CharacterFormatSpan, ...]
40
+ paragraph_spans: tuple[ParagraphFormatSpan, ...]
41
+ character_fkp_run_count: int = 0
42
+ paragraph_fkp_run_count: int = 0
43
+
44
+ def character_properties_at(self, cp: int) -> CharacterProperties:
45
+ if not self.character_spans:
46
+ return CharacterProperties()
47
+ index = bisect_right(
48
+ self.character_spans,
49
+ cp,
50
+ key=lambda span: span.cp_start,
51
+ ) - 1
52
+ if index >= 0:
53
+ span = self.character_spans[index]
54
+ if cp < span.cp_end:
55
+ return span.properties
56
+ return CharacterProperties()
57
+
58
+ def paragraph_properties_at(self, cp: int) -> ParagraphProperties:
59
+ if not self.paragraph_spans:
60
+ return ParagraphProperties()
61
+ index = bisect_right(
62
+ self.paragraph_spans,
63
+ cp,
64
+ key=lambda span: span.cp_start,
65
+ ) - 1
66
+ if index >= 0:
67
+ span = self.paragraph_spans[index]
68
+ if cp < span.cp_end:
69
+ return span.properties
70
+ return ParagraphProperties()
71
+
72
+
73
+ def _read_bte_plc(
74
+ table_stream: bytes,
75
+ *,
76
+ offset: int,
77
+ size: int,
78
+ label: str,
79
+ ) -> tuple[tuple[int, int, int], ...]:
80
+ if size == 0:
81
+ return ()
82
+ if offset < 0 or size < 0 or offset > len(table_stream) - size:
83
+ raise InvalidWordDocument(
84
+ f"{label} range [{offset}, {offset + size}) exceeds Table stream"
85
+ )
86
+ data = table_stream[offset : offset + size]
87
+ if len(data) < 12 or (len(data) - 4) % 8:
88
+ raise InvalidWordDocument(
89
+ f"{label} size {len(data)} does not match the 8*n+4 PLC layout"
90
+ )
91
+ count = (len(data) - 4) // 8
92
+ fcs = struct.unpack_from(f"<{count + 1}I", data, 0)
93
+ for previous, current in zip(fcs, fcs[1:]):
94
+ if current <= previous:
95
+ raise InvalidWordDocument(f"{label} FC values are not increasing")
96
+ page_offset = 4 * (count + 1)
97
+ pages = struct.unpack_from(f"<{count}I", data, page_offset)
98
+ return tuple((fcs[index], fcs[index + 1], pages[index]) for index in range(count))
99
+
100
+
101
+ def _read_fkp_page(word_document: bytes, page_number: int, *, label: str) -> bytes:
102
+ offset = page_number * FKP_SIZE
103
+ if offset < 0 or offset > len(word_document) - FKP_SIZE:
104
+ raise InvalidWordDocument(
105
+ f"{label} page {page_number} at WordDocument offset {offset} is truncated"
106
+ )
107
+ return word_document[offset : offset + FKP_SIZE]
108
+
109
+
110
+ def _run_boundaries(page: bytes, count: int, *, label: str) -> tuple[int, ...]:
111
+ if count == 0:
112
+ raise InvalidWordDocument(f"{label} contains zero runs")
113
+ fcs = struct.unpack_from(f"<{count + 1}I", page, 0)
114
+ for previous, current in zip(fcs, fcs[1:]):
115
+ if current <= previous:
116
+ raise InvalidWordDocument(f"{label} FC values are not increasing")
117
+ return fcs
118
+
119
+
120
+ def _merge_character_spans(
121
+ spans: list[CharacterFormatSpan],
122
+ ) -> tuple[CharacterFormatSpan, ...]:
123
+ result: list[CharacterFormatSpan] = []
124
+ for span in sorted(spans, key=lambda item: (item.cp_start, item.cp_end)):
125
+ if span.cp_start >= span.cp_end:
126
+ continue
127
+ if (
128
+ result
129
+ and result[-1].cp_end == span.cp_start
130
+ and result[-1].properties == span.properties
131
+ ):
132
+ previous = result[-1]
133
+ result[-1] = CharacterFormatSpan(
134
+ previous.cp_start,
135
+ span.cp_end,
136
+ span.properties,
137
+ )
138
+ else:
139
+ result.append(span)
140
+ return tuple(result)
141
+
142
+
143
+ def _merge_paragraph_spans(
144
+ spans: list[ParagraphFormatSpan],
145
+ ) -> tuple[ParagraphFormatSpan, ...]:
146
+ result: list[ParagraphFormatSpan] = []
147
+ for span in sorted(spans, key=lambda item: (item.cp_start, item.cp_end)):
148
+ if span.cp_start >= span.cp_end:
149
+ continue
150
+ if (
151
+ result
152
+ and result[-1].cp_end == span.cp_start
153
+ and result[-1].properties == span.properties
154
+ ):
155
+ previous = result[-1]
156
+ result[-1] = ParagraphFormatSpan(
157
+ previous.cp_start,
158
+ span.cp_end,
159
+ span.properties,
160
+ )
161
+ else:
162
+ result.append(span)
163
+ return tuple(result)
164
+
165
+
166
+ def read_formatting(
167
+ table_stream: bytes,
168
+ word_document: bytes,
169
+ piece_table: PieceTable,
170
+ *,
171
+ fc_plcf_bte_chpx: int,
172
+ lcb_plcf_bte_chpx: int,
173
+ fc_plcf_bte_papx: int,
174
+ lcb_plcf_bte_papx: int,
175
+ report: ConversionReport,
176
+ fonts: tuple[FontDefinition, ...] = (),
177
+ style_sheet: StyleSheet | None = None,
178
+ ) -> FormattingMap:
179
+ style_sheet = style_sheet or StyleSheet()
180
+ font_names = {font.index: font.name for font in fonts}
181
+ character_btes = _read_bte_plc(
182
+ table_stream,
183
+ offset=fc_plcf_bte_chpx,
184
+ size=lcb_plcf_bte_chpx,
185
+ label="PlcBteChpx",
186
+ )
187
+ paragraph_btes = _read_bte_plc(
188
+ table_stream,
189
+ offset=fc_plcf_bte_papx,
190
+ size=lcb_plcf_bte_papx,
191
+ label="PlcBtePapx",
192
+ )
193
+
194
+ character_spans: list[CharacterFormatSpan] = []
195
+ paragraph_spans: list[ParagraphFormatSpan] = []
196
+ unsupported_character_sprms: set[int] = set()
197
+ unsupported_paragraph_sprms: set[int] = set()
198
+ style_relative_toggles = 0
199
+ character_run_count = 0
200
+ paragraph_run_count = 0
201
+
202
+ # PAPX is parsed first because style-relative CHPX toggles depend on the
203
+ # paragraph style active at each CP.
204
+ for bte_fc_start, bte_fc_end, page_number in paragraph_btes:
205
+ page = _read_fkp_page(word_document, page_number, label="PapxFkp")
206
+ run_count = page[-1]
207
+ header_end = 4 * (run_count + 1) + 13 * run_count
208
+ if run_count == 0 or header_end > FKP_SIZE - 1:
209
+ raise InvalidWordDocument(f"PapxFkp has invalid cpara {run_count}")
210
+ fcs = _run_boundaries(page, run_count, label="PapxFkp")
211
+ bx_offset = 4 * (run_count + 1)
212
+ for index in range(run_count):
213
+ run_fc_start = max(fcs[index], bte_fc_start)
214
+ run_fc_end = min(fcs[index + 1], bte_fc_end)
215
+ if run_fc_start >= run_fc_end:
216
+ continue
217
+ paragraph_run_count += 1
218
+ papx_offset = page[bx_offset + index * 13] * 2
219
+ properties = ParagraphProperties()
220
+ if papx_offset:
221
+ if papx_offset >= FKP_SIZE - 1:
222
+ raise InvalidWordDocument("Papx offset points outside PapxFkp")
223
+ byte_count = page[papx_offset]
224
+ if byte_count:
225
+ content_start = papx_offset + 1
226
+ content_length = byte_count * 2 - 1
227
+ else:
228
+ if papx_offset + 1 >= FKP_SIZE - 1:
229
+ raise InvalidWordDocument("extended PapxInFkp is truncated")
230
+ content_start = papx_offset + 2
231
+ content_length = page[papx_offset + 1] * 2
232
+ content_end = content_start + content_length
233
+ if content_length < 2 or content_end > FKP_SIZE - 1:
234
+ raise InvalidWordDocument("PapxInFkp content exceeds PapxFkp")
235
+ style_id = struct.unpack_from("<H", page, content_start)[0]
236
+ modifiers = parse_grpprl(
237
+ page[content_start + 2 : content_end],
238
+ label="PapxInFkp.grpprl",
239
+ allow_trailing_zero_padding=True,
240
+ )
241
+ properties, unsupported = apply_paragraph_modifiers(
242
+ modifiers,
243
+ style_id=style_id,
244
+ )
245
+ unsupported_paragraph_sprms.update(unsupported)
246
+ for cp_start, cp_end in piece_table.fc_range_to_cp_ranges(
247
+ run_fc_start,
248
+ run_fc_end,
249
+ ):
250
+ paragraph_spans.append(
251
+ ParagraphFormatSpan(cp_start, cp_end, properties)
252
+ )
253
+
254
+ def paragraph_at(cp: int) -> ParagraphProperties:
255
+ for span in paragraph_spans:
256
+ if span.cp_start <= cp < span.cp_end:
257
+ return span.properties
258
+ return ParagraphProperties()
259
+
260
+ # Piece Prm paragraph modifiers are applied after the PAPX grpprl.
261
+ paragraph_boundaries = {
262
+ boundary
263
+ for piece in piece_table.pieces
264
+ for boundary in (piece.cp_start, piece.cp_end)
265
+ }
266
+ paragraph_boundaries.update(
267
+ boundary
268
+ for span in paragraph_spans
269
+ for boundary in (span.cp_start, span.cp_end)
270
+ )
271
+ composed_paragraph_spans: list[ParagraphFormatSpan] = []
272
+ sorted_paragraph_boundaries = sorted(paragraph_boundaries)
273
+ for cp_start, cp_end in zip(
274
+ sorted_paragraph_boundaries,
275
+ sorted_paragraph_boundaries[1:],
276
+ ):
277
+ if cp_start >= cp_end:
278
+ continue
279
+ properties = paragraph_at(cp_start)
280
+ piece = next(
281
+ (
282
+ value
283
+ for value in piece_table.pieces
284
+ if value.cp_start <= cp_start < value.cp_end
285
+ ),
286
+ None,
287
+ )
288
+ if piece is not None:
289
+ modifiers = piece_table.modifiers_for_piece(piece, property_group=1)
290
+ if modifiers:
291
+ properties, unsupported = apply_paragraph_modifiers(
292
+ modifiers,
293
+ style_id=properties.style_id,
294
+ initial_properties=properties,
295
+ )
296
+ unsupported_paragraph_sprms.update(unsupported)
297
+ composed_paragraph_spans.append(
298
+ ParagraphFormatSpan(cp_start, cp_end, properties)
299
+ )
300
+ paragraph_spans = list(_merge_paragraph_spans(composed_paragraph_spans))
301
+
302
+ def paragraph_at(cp: int) -> ParagraphProperties:
303
+ for span in paragraph_spans:
304
+ if span.cp_start <= cp < span.cp_end:
305
+ return span.properties
306
+ return ParagraphProperties()
307
+
308
+ def paragraph_style_character_at(cp: int) -> CharacterProperties:
309
+ return style_sheet.effective_character_at(paragraph_at(cp).style_id)
310
+
311
+ paragraph_style_boundaries = {
312
+ boundary
313
+ for span in paragraph_spans
314
+ for boundary in (span.cp_start, span.cp_end)
315
+ }
316
+
317
+ for bte_fc_start, bte_fc_end, page_number in character_btes:
318
+ page = _read_fkp_page(word_document, page_number, label="ChpxFkp")
319
+ run_count = page[-1]
320
+ if run_count > 0x65:
321
+ raise InvalidWordDocument(f"ChpxFkp has invalid crun {run_count}")
322
+ header_end = 4 * (run_count + 1) + run_count
323
+ if header_end > FKP_SIZE - 1:
324
+ raise InvalidWordDocument("ChpxFkp header exceeds its 512-byte page")
325
+ fcs = _run_boundaries(page, run_count, label="ChpxFkp")
326
+ rgb_offset = 4 * (run_count + 1)
327
+ for index in range(run_count):
328
+ run_fc_start = max(fcs[index], bte_fc_start)
329
+ run_fc_end = min(fcs[index + 1], bte_fc_end)
330
+ if run_fc_start >= run_fc_end:
331
+ continue
332
+ character_run_count += 1
333
+ chpx_offset = page[rgb_offset + index] * 2
334
+ modifiers = ()
335
+ if chpx_offset:
336
+ if chpx_offset >= FKP_SIZE - 1:
337
+ raise InvalidWordDocument("Chpx offset points outside ChpxFkp")
338
+ byte_count = page[chpx_offset]
339
+ grpprl_end = chpx_offset + 1 + byte_count
340
+ if grpprl_end > FKP_SIZE - 1:
341
+ raise InvalidWordDocument("Chpx grpprl exceeds ChpxFkp")
342
+ modifiers = parse_grpprl(
343
+ page[chpx_offset + 1 : grpprl_end],
344
+ label="Chpx.grpprl",
345
+ )
346
+ for cp_start, cp_end in piece_table.fc_range_to_cp_ranges(
347
+ run_fc_start,
348
+ run_fc_end,
349
+ ):
350
+ boundaries = [cp_start]
351
+ boundaries.extend(
352
+ boundary
353
+ for boundary in paragraph_style_boundaries
354
+ if cp_start < boundary < cp_end
355
+ )
356
+ boundaries.append(cp_end)
357
+ for span_start, span_end in zip(boundaries, boundaries[1:]):
358
+ properties, unsupported, relative_count = (
359
+ apply_character_modifiers(
360
+ modifiers,
361
+ base_properties=paragraph_style_character_at(span_start),
362
+ font_names=font_names,
363
+ style_properties_at=style_sheet.effective_character_at,
364
+ )
365
+ )
366
+ unsupported_character_sprms.update(unsupported)
367
+ style_relative_toggles += relative_count
368
+ character_spans.append(
369
+ CharacterFormatSpan(span_start, span_end, properties)
370
+ )
371
+
372
+ def character_at(cp: int) -> CharacterProperties:
373
+ for span in character_spans:
374
+ if span.cp_start <= cp < span.cp_end:
375
+ return span.properties
376
+ return CharacterProperties()
377
+
378
+ # Piece Prm character modifiers are applied after the CHPX grpprl.
379
+ character_boundaries = {
380
+ boundary
381
+ for piece in piece_table.pieces
382
+ for boundary in (piece.cp_start, piece.cp_end)
383
+ }
384
+ character_boundaries.update(
385
+ boundary
386
+ for span in character_spans
387
+ for boundary in (span.cp_start, span.cp_end)
388
+ )
389
+ character_boundaries.update(paragraph_style_boundaries)
390
+ composed_character_spans: list[CharacterFormatSpan] = []
391
+ sorted_character_boundaries = sorted(character_boundaries)
392
+ for cp_start, cp_end in zip(
393
+ sorted_character_boundaries,
394
+ sorted_character_boundaries[1:],
395
+ ):
396
+ if cp_start >= cp_end:
397
+ continue
398
+ properties = character_at(cp_start)
399
+ piece = next(
400
+ (
401
+ value
402
+ for value in piece_table.pieces
403
+ if value.cp_start <= cp_start < value.cp_end
404
+ ),
405
+ None,
406
+ )
407
+ if piece is not None:
408
+ modifiers = piece_table.modifiers_for_piece(piece, property_group=2)
409
+ if modifiers:
410
+ properties, unsupported, relative_count = apply_character_modifiers(
411
+ modifiers,
412
+ initial_properties=properties,
413
+ base_properties=paragraph_style_character_at(cp_start),
414
+ font_names=font_names,
415
+ style_properties_at=style_sheet.effective_character_at,
416
+ )
417
+ unsupported_character_sprms.update(unsupported)
418
+ style_relative_toggles += relative_count
419
+ composed_character_spans.append(
420
+ CharacterFormatSpan(cp_start, cp_end, properties)
421
+ )
422
+ character_spans = list(_merge_character_spans(composed_character_spans))
423
+
424
+ if unsupported_character_sprms:
425
+ report.warning(
426
+ "UNSUPPORTED_CHARACTER_SPRMS",
427
+ "some direct character properties are not yet supported",
428
+ opcodes=[f"0x{value:04X}" for value in sorted(unsupported_character_sprms)],
429
+ )
430
+ if unsupported_paragraph_sprms:
431
+ report.warning(
432
+ "UNSUPPORTED_PARAGRAPH_SPRMS",
433
+ "some direct paragraph properties are not yet supported",
434
+ opcodes=[f"0x{value:04X}" for value in sorted(unsupported_paragraph_sprms)],
435
+ )
436
+ referenced_style_ids = {
437
+ properties.style_id
438
+ for properties in (
439
+ [span.properties for span in paragraph_spans]
440
+ + [span.properties for span in character_spans]
441
+ )
442
+ if properties.style_id is not None
443
+ }
444
+ missing_style_ids = sorted(
445
+ style_id
446
+ for style_id in referenced_style_ids
447
+ if style_id != 0 and style_sheet.style_at(style_id) is None
448
+ )
449
+ if missing_style_ids:
450
+ report.warning(
451
+ "MISSING_REFERENCED_STYLES",
452
+ "some formatting runs reference absent or unsupported styles",
453
+ style_ids=missing_style_ids,
454
+ )
455
+ if style_relative_toggles:
456
+ report.info(
457
+ "STYLE_RELATIVE_TOGGLES_RESOLVED",
458
+ "style-relative character toggles were resolved through STSH inheritance",
459
+ count=style_relative_toggles,
460
+ )
461
+
462
+ return FormattingMap(
463
+ tuple(character_spans),
464
+ tuple(paragraph_spans),
465
+ character_run_count,
466
+ paragraph_run_count,
467
+ )
@@ -0,0 +1,184 @@
1
+ """Header/footer story extraction and section association from PlcfHdd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass, replace
7
+ import struct
8
+
9
+ from ..diagnostics import ConversionReport, SourceLocation
10
+ from ..errors import InvalidWordDocument
11
+ from ..model import (
12
+ CharacterProperties,
13
+ HeaderFooterStory,
14
+ ParagraphProperties,
15
+ SectionProperties,
16
+ StoryCharacter,
17
+ parse_main_story,
18
+ )
19
+ from .pieces import PieceTable
20
+
21
+
22
+ _STORIES_PER_SECTION = (
23
+ ("even_header", "even header"),
24
+ ("default_header", "default header"),
25
+ ("even_footer", "even footer"),
26
+ ("default_footer", "default footer"),
27
+ ("first_header", "first-page header"),
28
+ ("first_footer", "first-page footer"),
29
+ )
30
+
31
+
32
+ @dataclass(slots=True, frozen=True)
33
+ class HeaderFooterCollection:
34
+ sections: tuple[SectionProperties, ...]
35
+ story_count: int = 0
36
+ paragraph_count: int = 0
37
+
38
+
39
+ def _read_story_cps(
40
+ table_stream: bytes,
41
+ *,
42
+ offset: int,
43
+ size: int,
44
+ ccp_headers: int,
45
+ section_count: int,
46
+ ) -> tuple[int, ...]:
47
+ if offset < 0 or size < 0 or offset > len(table_stream) - size:
48
+ raise InvalidWordDocument(
49
+ f"PlcfHdd range [{offset}, {offset + size}) exceeds Table stream"
50
+ )
51
+ story_count = 6 + 6 * section_count
52
+ expected_cp_count = story_count + 2
53
+ expected_size = expected_cp_count * 4
54
+ if size != expected_size:
55
+ raise InvalidWordDocument(
56
+ f"PlcfHdd size {size} does not contain {expected_cp_count} CP values "
57
+ f"for {section_count} section(s)"
58
+ )
59
+ cps = struct.unpack_from(f"<{expected_cp_count}I", table_stream, offset)
60
+ story_cps = cps[:-1] # The final PlcfHdd CP is undefined by MS-DOC.
61
+ if not story_cps or story_cps[0] != 0:
62
+ raise InvalidWordDocument("PlcfHdd does not begin at header-story CP 0")
63
+ for previous, current in zip(story_cps, story_cps[1:]):
64
+ if current < previous:
65
+ raise InvalidWordDocument("PlcfHdd story CP values are decreasing")
66
+ if ccp_headers < 1 or story_cps[-1] != ccp_headers - 1:
67
+ raise InvalidWordDocument(
68
+ "PlcfHdd second-to-last CP does not equal ccpHdd minus one"
69
+ )
70
+ if any(cp >= ccp_headers for cp in story_cps):
71
+ raise InvalidWordDocument("PlcfHdd story CP points beyond the header document")
72
+ return story_cps
73
+
74
+
75
+ def read_header_footer_stories(
76
+ table_stream: bytes,
77
+ piece_table: PieceTable,
78
+ sections: Sequence[SectionProperties],
79
+ *,
80
+ offset: int,
81
+ size: int,
82
+ ccp_headers: int,
83
+ header_story_cp_start: int,
84
+ report: ConversionReport,
85
+ character_properties_at: Callable[[int], CharacterProperties] | None = None,
86
+ paragraph_properties_at: Callable[[int], ParagraphProperties] | None = None,
87
+ ) -> HeaderFooterCollection:
88
+ """Read non-empty header/footer stories and attach them to their sections."""
89
+
90
+ section_values = tuple(sections)
91
+ if ccp_headers == 0 and size == 0:
92
+ return HeaderFooterCollection(section_values)
93
+ if ccp_headers == 0 or size == 0:
94
+ raise InvalidWordDocument(
95
+ "header document and PlcfHdd must either both exist or both be absent"
96
+ )
97
+ header_story_cp_end = header_story_cp_start + ccp_headers
98
+ if header_story_cp_end > piece_table.cp_end:
99
+ raise InvalidWordDocument(
100
+ f"header story range [{header_story_cp_start}, {header_story_cp_end}) "
101
+ f"exceeds Piece Table CP {piece_table.cp_end}"
102
+ )
103
+ story_cps = _read_story_cps(
104
+ table_stream,
105
+ offset=offset,
106
+ size=size,
107
+ ccp_headers=ccp_headers,
108
+ section_count=len(section_values),
109
+ )
110
+
111
+ def content_units(
112
+ story_index: int,
113
+ story_name: str,
114
+ ) -> tuple[StoryCharacter, ...] | None:
115
+ relative_start = story_cps[story_index]
116
+ relative_end = story_cps[story_index + 1]
117
+ if relative_start == relative_end:
118
+ return None
119
+ cp_start = header_story_cp_start + relative_start
120
+ cp_end = header_story_cp_start + relative_end
121
+ units = piece_table.extract_characters(
122
+ cp_start,
123
+ cp_end,
124
+ report,
125
+ story=story_name,
126
+ )
127
+ if not units or units[-1].text != "\r" or units[-1].cp_end != cp_end:
128
+ raise InvalidWordDocument(
129
+ f"non-empty PlcfHdd story {story_index} has no guard paragraph mark"
130
+ )
131
+ return units[:-1]
132
+
133
+ nonempty_separators = 0
134
+ for story_index in range(6):
135
+ if content_units(story_index, f"note-separator-{story_index}") is not None:
136
+ nonempty_separators += 1
137
+ if nonempty_separators:
138
+ report.warning(
139
+ "NOTE_SEPARATOR_STORIES_DEFERRED",
140
+ "footnote or endnote separator stories are not yet emitted",
141
+ location=SourceLocation(story="headers"),
142
+ story_count=nonempty_separators,
143
+ )
144
+
145
+ parsed_story_count = 0
146
+ paragraph_count = 0
147
+ resolved_sections: list[SectionProperties] = []
148
+ for section_index, section in enumerate(section_values):
149
+ replacements: dict[str, HeaderFooterStory] = {}
150
+ group_start = 6 + 6 * section_index
151
+ for group_index, (field_name, _display_name) in enumerate(
152
+ _STORIES_PER_SECTION
153
+ ):
154
+ story_index = group_start + group_index
155
+ story_name = f"section-{section_index}-{field_name.replace('_', '-')}"
156
+ units = content_units(story_index, story_name)
157
+ if units is None:
158
+ continue
159
+ if not units or units[-1].text != "\r":
160
+ raise InvalidWordDocument(
161
+ f"non-empty header/footer story {story_index} is not a whole paragraph"
162
+ )
163
+ parsed = parse_main_story(
164
+ units,
165
+ report,
166
+ character_properties_at=character_properties_at,
167
+ paragraph_properties_at=paragraph_properties_at,
168
+ story_name=story_name,
169
+ )
170
+ replacements[field_name] = HeaderFooterStory(
171
+ cp_start=units[0].cp_start,
172
+ cp_end=units[-1].cp_end,
173
+ paragraphs=parsed.paragraphs,
174
+ blocks=parsed.blocks,
175
+ )
176
+ parsed_story_count += 1
177
+ paragraph_count += len(parsed.paragraphs)
178
+ resolved_sections.append(replace(section, **replacements))
179
+
180
+ return HeaderFooterCollection(
181
+ sections=tuple(resolved_sections),
182
+ story_count=parsed_story_count,
183
+ paragraph_count=paragraph_count,
184
+ )