@godcreator/word-report 0.6.0 → 0.6.1

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.
@@ -10,6 +10,8 @@ import re
10
10
  from docreport.plain_math import INLINE_MATH_STYLE
11
11
  from docreport.word import utf16_length, find_text, MarkerFinder
12
12
 
13
+ BODY_MARKER = re.compile(r'@(?:FIELD|NUMSTART|NUMEND|LINK|NOTE)\d+@')
14
+
13
15
 
14
16
  def mm(value):
15
17
  """Convert millimetres to Word points."""
@@ -227,30 +229,74 @@ class ContentComposer(Composer):
227
229
  self.appendix_numbering = None
228
230
  self.appendix_count = 0
229
231
  self._inline_math_style = None
232
+ # Each COM call crosses into the Word process; style objects, the bookmark collection
233
+ # and the start of the paragraph just written are fetched once and reused.
234
+ self._style_objects = {}
235
+ self._bookmark_collection = doc.Bookmarks
236
+ self._last_range, self._last_start = None, None
237
+ self._token_positions = {}
238
+
239
+ def style_object(self, name):
240
+ style = self._style_objects.get(name)
241
+ if style is None:
242
+ style = self._style_objects[name] = self.doc.Styles.Item(name)
243
+ return style
244
+
245
+ def in_main_story(self):
246
+ """Body paragraphs and body table cells; a footnote container is another story."""
247
+ return self.container is None or self.cell
248
+
249
+ def range_at(self, start, end):
250
+ if self.in_main_story():
251
+ return self.doc.Range(start, end)
252
+ rng = self.container.Range.Duplicate
253
+ rng.SetRange(start, end)
254
+ return rng
230
255
 
231
- def point(self):
232
- rng = self.doc.Content.Duplicate if self.container is None else self.container.Range.Duplicate
256
+ def insertion_point(self):
257
+ if self.container is None:
258
+ position = self.doc.Content.End - 1
259
+ return self.doc.Range(position, position), position
260
+ rng = self.container.Range.Duplicate
233
261
  # Word exposes the cell's CR/BEL terminator as one Range position.
234
262
  position = rng.End - 1
235
263
  rng.SetRange(position, position)
236
- return rng
264
+ return rng, position
265
+
266
+ def point(self):
267
+ return self.insertion_point()[0]
237
268
 
238
269
  def paragraph(self, text, style=None):
239
- rng = self.point()
240
- start = rng.Start
270
+ rng, start = self.insertion_point()
271
+ # InsertBefore expands the range over the inserted text and paragraph mark.
241
272
  rng.InsertBefore(text + '\r')
242
- rng.SetRange(start, start + utf16_length(text) + 1)
243
- rng.Style = self.doc.Styles.Item(self.style if style is None else style)
273
+ rng.Style = self.style_object(self.style if style is None else style)
274
+ self._last_range, self._last_start = rng, start
275
+ self.record_tokens(text, start)
244
276
  return rng
245
277
 
278
+ def record_tokens(self, text, start):
279
+ """Body positions of field, number, note and link markers, so finish() needs no Find.
280
+
281
+ Content is only appended after a recorded marker, so its position holds until
282
+ build() replaces markers; notes and links shift later positions, fields go back to front.
283
+ """
284
+ if not self.in_main_story():
285
+ return
286
+ for match in BODY_MARKER.finditer(text):
287
+ self._token_positions[match.group()] = start + utf16_length(text[:match.start()])
288
+
246
289
  def part(self, rng, offset, length):
290
+ if rng is self._last_range:
291
+ return self.range_at(self._last_start + offset, self._last_start + offset + length)
247
292
  result = rng.Duplicate
248
- result.SetRange(rng.Start + offset, rng.Start + offset + length)
293
+ start = rng.Start
294
+ result.SetRange(start + offset, start + offset + length)
249
295
  return result
250
296
 
251
297
  def mark(self, identifier, rng, suffix=''):
252
298
  from .content import bookmark
253
- self.doc.Bookmarks.Add(bookmark(identifier, suffix), rng)
299
+ self._bookmark_collection.Add(bookmark(identifier, suffix), rng)
254
300
 
255
301
  def math_token(self, node, inline):
256
302
  from .content import bookmark
@@ -315,21 +361,29 @@ class ContentComposer(Composer):
315
361
  def rich_paragraph(self, nodes, style=None, prefix='', suffix=''):
316
362
  text, spans = self.inline(nodes)
317
363
  rng = self.paragraph(prefix + text + suffix, style)
318
- self.apply_marks(rng, utf16_length(prefix), spans)
364
+ self.apply_marks_at(self._last_start + utf16_length(prefix), spans)
319
365
  return rng
320
366
 
321
367
  def apply_marks(self, rng, offset, spans):
368
+ start = self._last_start if rng is self._last_range else rng.Start
369
+ self.apply_marks_at(start + offset, spans)
370
+
371
+ def apply_marks_at(self, base, spans):
322
372
  for start, length, marks in spans:
323
373
  if not marks:
324
374
  continue
325
- run = self.part(rng, offset + start, length)
375
+ run = self.range_at(base + start, base + start + length)
326
376
  for mark in marks:
327
377
  if mark == 'code':
328
378
  run.Font.Name = 'Consolas'
329
379
  elif mark == 'math':
330
380
  run.Style = self.inline_math_style()
381
+ elif mark == 'strong':
382
+ run.Bold = True
383
+ elif mark == 'em':
384
+ run.Italic = True
331
385
  else:
332
- setattr(run.Font, {'strong': 'Bold', 'em': 'Italic', 'sup': 'Superscript', 'sub': 'Subscript'}[mark], True)
386
+ setattr(run.Font, {'sup': 'Superscript', 'sub': 'Subscript'}[mark], True)
333
387
 
334
388
  def inline_math_style(self):
335
389
  """Character style of inline formulas written as text; created in the document, the DOTX stays unchanged."""
@@ -343,11 +397,13 @@ class ContentComposer(Composer):
343
397
  """Write a one-paragraph cell in one assignment; the general path inserts then trims a paragraph."""
344
398
  text, spans = self.inline(node['content'])
345
399
  rng = cell.Range
400
+ start = rng.Start
346
401
  rng.Text = text
347
- rng = self.part(cell.Range, 0, utf16_length(text))
348
- rng.Style = self.doc.Styles.Item(self.layout.get(node['id'], {}).get('style') or self.style)
349
- self.apply_marks(rng, 0, spans)
350
- self.mark(node['id'], rng, '_b')
402
+ self.record_tokens(text, start)
403
+ target = self.range_at(start, start + utf16_length(text))
404
+ target.Style = self.style_object(self.layout.get(node['id'], {}).get('style') or self.style)
405
+ self.apply_marks_at(start, spans)
406
+ self.mark(node['id'], target, '_b')
351
407
 
352
408
  def chapter_sequence(self, kind, bookmark):
353
409
  """「章号-序号」 for an equation, figure or table in the current top-level chapter."""
@@ -494,7 +550,7 @@ class ContentComposer(Composer):
494
550
  table.AllowAutoFit = False
495
551
  table.Borders.Enable = 1
496
552
  table.Rows.AllowBreakAcrossPages = 0
497
- table.Range.Style = self.doc.Styles.Item('Table Text')
553
+ table.Range.Style = self.style_object('Table Text')
498
554
  table.TopPadding = table.BottomPadding = 3
499
555
  table.LeftPadding = table.RightPadding = 4
500
556
  layout = self.layout.get(node['id'], {})
@@ -512,14 +568,19 @@ class ContentComposer(Composer):
512
568
  table.Rows.Item(r).Shading.BackgroundPatternColor = 0xEFEFEF
513
569
  if layout.get('minRowHeightPt'):
514
570
  table.Rows.HeightRule, table.Rows.Height = 1, layout['minRowHeightPt']
515
- cells = {(r, c): table.Cell(r + 1, c + 1).Range.Duplicate
516
- for r in range(len(node['rows'])) for c in range(len(node['columns']))}
517
- for r, c, rs, cs, cell in reversed(placements):
518
- if rs > 1 or cs > 1:
519
- cells[r, c].Cells.Item(1).Merge(cells[r + rs - 1, c + cs - 1].Cells.Item(1))
571
+ if any(rs > 1 or cs > 1 for _, _, rs, cs, _ in placements):
572
+ # Merging renumbers cells; ranges taken before merging still find each placed cell.
573
+ cells = {(r, c): table.Cell(r + 1, c + 1).Range.Duplicate
574
+ for r in range(len(node['rows'])) for c in range(len(node['columns']))}
575
+ for r, c, rs, cs, cell in reversed(placements):
576
+ if rs > 1 or cs > 1:
577
+ cells[r, c].Cells.Item(1).Merge(cells[r + rs - 1, c + cs - 1].Cells.Item(1))
578
+ cell_at = lambda r, c: cells[r, c].Cells.Item(1)
579
+ else:
580
+ cell_at = lambda r, c: table.Cell(r + 1, c + 1)
520
581
  saved = self.container, self.cell, self.style, self.width
521
582
  for r, c, rs, cs, cell in placements:
522
- self.container = cells[r, c].Cells.Item(1)
583
+ self.container = cell_at(r, c)
523
584
  self.cell = True
524
585
  self.style = 'Table Header' if r < node['headerRows'] else 'Table Text'
525
586
  self.width = sum(widths[c:c + cs]) - 8
@@ -608,6 +669,92 @@ class ContentComposer(Composer):
608
669
  else:
609
670
  raise ValueError(f'Unsupported block: {kind}')
610
671
 
672
+ def replace_body_marker(self, token, replace):
673
+ """Replace a note or link marker; markers recorded after it move by the change in body length."""
674
+ position = self._token_positions.pop(token, None)
675
+ if position is None:
676
+ replace(find_text(self.doc, token))
677
+ return
678
+ before = self.doc.Content.End
679
+ replace(self.doc.Range(position, position + utf16_length(token)))
680
+ delta = self.doc.Content.End - before
681
+ if delta:
682
+ for marker, recorded in self._token_positions.items():
683
+ if recorded > position:
684
+ self._token_positions[marker] = recorded + delta
685
+
686
+ def compose_note(self, target, identifier):
687
+ target.Text = ''
688
+ note = self.doc.Footnotes.Add(Range=target, Text='')
689
+ saved = self.container, self.cell, self.style, self.width
690
+ self.container, self.cell, self.style = note, False, -30
691
+ self.blocks(self.context['index'][identifier]['blocks'])
692
+ self.trim_container()
693
+ self.container, self.cell, self.style, self.width = saved
694
+
695
+ def add_link(self, target, text, url):
696
+ # Word can retain a nonempty anchor's placeholder even when
697
+ # TextToDisplay is supplied. Replace the range explicitly before
698
+ # adding the hyperlink so the saved document contains source text.
699
+ target.Text = text
700
+ self.doc.Hyperlinks.Add(target, url)
701
+
702
+ def add_field(self, token, rng, code, bookmark):
703
+ field = self.doc.Fields.Add(rng, -1, code, False)
704
+ font = field.Code.Font
705
+ font.Bold, font.Italic = 0, 0
706
+ if bookmark:
707
+ self._bookmark_collection.Add(bookmark, field.Result)
708
+
709
+ def finish(self):
710
+ """Body markers go back to front, so each replacement leaves earlier positions unchanged.
711
+
712
+ A collapsed or adjacent Word range does not follow a field inserted right before it,
713
+ so the end of a 「章号-序号」 bookmark is tracked from the change in body length.
714
+ Markers inside footnotes are not recorded and are found by searching.
715
+ """
716
+ if self._finished:
717
+ raise RuntimeError('Composer.finish() may only be called once.')
718
+ doc = self.doc
719
+ fields = {token: (code, bookmark) for token, code, bookmark in self._pending_fields}
720
+ openings = {begin: (end, bookmark) for begin, end, bookmark in self._pending_numbers}
721
+ closings = {end for _, end, _ in self._pending_numbers}
722
+ open_number = None
723
+ for position, token in sorted(((p, t) for t, p in self._token_positions.items()), reverse=True):
724
+ rng = doc.Range(position, position + utf16_length(token))
725
+ if token in fields:
726
+ if open_number is None:
727
+ self.add_field(token, rng, *fields.pop(token))
728
+ continue
729
+ before = doc.Content.End
730
+ self.add_field(token, rng, *fields.pop(token))
731
+ open_number[1] += doc.Content.End - before
732
+ elif token in closings:
733
+ if open_number is not None:
734
+ raise RuntimeError(f'Number marker nested in another number: {token}')
735
+ rng.Delete()
736
+ open_number = [token, position]
737
+ elif token in openings:
738
+ end_token, bookmark = openings.pop(token)
739
+ if open_number is None or open_number[0] != end_token:
740
+ raise RuntimeError(f'Number marker without its end: {token}')
741
+ rng.Delete()
742
+ self._bookmark_collection.Add(bookmark, doc.Range(position, open_number[1] - utf16_length(token)))
743
+ open_number = None
744
+ else:
745
+ raise RuntimeError(f'Body marker left unhandled: {token}')
746
+ if openings:
747
+ raise RuntimeError(f'Number markers outside the body: {sorted(openings)}')
748
+ finder = MarkerFinder(doc)
749
+ for token, code, bookmark in self._pending_fields:
750
+ if token in fields:
751
+ self.add_field(token, finder.find(token), *fields.pop(token))
752
+ for table in doc.Tables:
753
+ following = doc.Range(table.Range.End, table.Range.End).Paragraphs.First.Format
754
+ following.SpaceBefore = max(6, following.SpaceBefore)
755
+ self._finished = True
756
+ return self.pending_math
757
+
611
758
  def build(self):
612
759
  metadata = self.content['metadata']
613
760
  values = dict(reportTitle=metadata['title'], projectName='', author='、'.join(metadata.get('authors', [])),
@@ -623,21 +770,9 @@ class ContentComposer(Composer):
623
770
  self.doc.Bookmarks.Add('ReportBodyStart', self.doc.Range(start, start))
624
771
  self.blocks(self.content['blocks'])
625
772
  for token, identifier in self.note_tokens:
626
- target = find_text(self.doc, token)
627
- target.Text = ''
628
- note = self.doc.Footnotes.Add(Range=target, Text='')
629
- saved = self.container, self.cell, self.style, self.width
630
- self.container, self.cell, self.style = note, False, -30
631
- self.blocks(self.context['index'][identifier]['blocks'])
632
- self.trim_container()
633
- self.container, self.cell, self.style, self.width = saved
773
+ self.replace_body_marker(token, lambda target: self.compose_note(target, identifier))
634
774
  for token, text, url in self.links:
635
- target = find_text(self.doc, token)
636
- # Word can retain a nonempty anchor's placeholder even when
637
- # TextToDisplay is supplied. Replace the range explicitly before
638
- # adding the hyperlink so the saved document contains source text.
639
- target.Text = text
640
- self.doc.Hyperlinks.Add(target, url)
775
+ self.replace_body_marker(token, lambda target: self.add_link(target, text, url))
641
776
  pending = self.finish()
642
777
  for index, section in enumerate(self.doc.Sections, 1):
643
778
  if index > 1:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godcreator/word-report",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "author": {
5
5
  "name": "godcreator"
6
6
  },