-
-
Notifications
You must be signed in to change notification settings - Fork 100
Expand file tree
/
Copy pathmerge_and_build.py
More file actions
1147 lines (942 loc) · 40.8 KB
/
Copy pathmerge_and_build.py
File metadata and controls
1147 lines (942 loc) · 40.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
merge_and_build.py - Merge translated pages and build final outputs
Combines original steps 4-7: merge -> HTML -> TOC -> DOCX/EPUB/PDF
Usage: merge_and_build.py --temp-dir <path> [--title <title>] [--author <author>] [--lang <lang>]
"""
import os
import sys
import re
import glob
import shutil
import subprocess
import argparse
import html as _html_lib
from collections import Counter
from html.parser import HTMLParser
from pathlib import Path
from manifest import validate_for_merge
# =============================================================================
# Image structure validation helpers
# =============================================================================
# Markdown image: `` or ``.
# - Negative lookbehind on `\` skips escaped `\![...]` (renders as literal text).
# - Closing `)` is required — a missing `)` means the image won't render, so
# such a fragment must NOT count as a preserved image reference.
_MD_IMG_RE = re.compile(r'(?<!\\)!\[[^\]]*\]\(\s*([^)\s]+)[^)]*\)')
_VALID_ATTR_NAME_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_:.\-]*$')
class _ImgTagCollector(HTMLParser):
"""Collects every <img> tag found in fed text. Uses stdlib HTMLParser, which
correctly handles `>` inside quoted attribute values — unlike a plain
`<img\\b[^>]*>` regex, which would truncate at the first `>`."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.records = [] # list of (raw_tag_text, attrs_list)
def handle_starttag(self, tag, attrs):
if tag == 'img':
self.records.append((self.get_starttag_text(), list(attrs)))
handle_startendtag = handle_starttag
def _scan_img_tags(text):
"""Return (Counter of <img> srcs, list of (raw_tag, bad_attr_name) tuples).
Feeds the entire text to HTMLParser rather than pre-extracting tags via regex,
so quoted attribute values containing `>` are handled correctly."""
src_counts = Counter()
bad_attrs = []
parser = _ImgTagCollector()
try:
parser.feed(text)
parser.close()
except Exception as e:
bad_attrs.append(('<unparseable input>', f'<parser error: {e}>'))
return src_counts, bad_attrs
for raw_tag, attrs in parser.records:
for name, _ in attrs:
if not _VALID_ATTR_NAME_RE.match(name):
bad_attrs.append((raw_tag, name))
for name, val in attrs:
if name == 'src' and val:
src_counts[val] += 1
return src_counts, bad_attrs
def _scan_image_refs(text):
"""Return (Counter html_srcs, Counter md_srcs, list bad_attrs)."""
html_srcs, bad_attrs = _scan_img_tags(text)
md_srcs = Counter(_MD_IMG_RE.findall(text))
return html_srcs, md_srcs, bad_attrs
def _validate_chunk_images(temp_dir):
"""Verify each output_chunk*.md preserves the image structure of its chunk*.md.
Bad-attribute detection uses a per-chunk DELTA: a malformed <img> attribute
is flagged only if it appears in the output chunk but not in the source
chunk. This avoids false positives on code blocks that legitimately contain
deliberately-broken <img> examples — both chunks carry the same example, so
the delta is empty.
Returns False on any divergence; collects all errors and prints them
together so an agent can fix many chunks in one pass.
"""
temp_path = Path(temp_dir)
errors = []
for src_chunk in sorted(temp_path.glob('chunk*.md')):
if src_chunk.name.startswith('output_'):
continue
out_chunk = temp_path / f'output_{src_chunk.name}'
if not out_chunk.exists():
continue # missing-output is the manifest validator's job
src_html, src_md, src_bad = _scan_image_refs(src_chunk.read_text(encoding='utf-8'))
out_html, out_md, out_bad = _scan_image_refs(out_chunk.read_text(encoding='utf-8'))
src_bad_counts = Counter(name for _, name in src_bad)
out_bad_counts = Counter(name for _, name in out_bad)
new_bad_counts = out_bad_counts - src_bad_counts
if new_bad_counts:
new_bad_examples = [
(raw_tag, attr_name)
for raw_tag, attr_name in out_bad
if new_bad_counts.get(attr_name, 0) > 0
]
for raw_tag, attr_name in new_bad_examples:
errors.append(
f"ERROR: {out_chunk.name} introduced malformed <img> tag (not present in source)\n"
f" tag: {raw_tag}\n"
f" problem: attribute name '{attr_name}' is not a valid HTML identifier\n"
f" likely cause: an unescaped quote inside alt=\"...\" or title=\"...\" closed the attribute early\n"
f" fix: in {out_chunk.name}, replace the inner quote with a curly quote in the target language or with " / '\n"
f" source chunk for reference: {src_chunk.name}"
)
if src_html != out_html or src_md != out_md:
errors.append(
f"ERROR: {out_chunk.name} image references diverge from {src_chunk.name}\n"
f" missing <img src> (count): {sorted((src_html - out_html).items()) or 'none'}\n"
f" extra <img src> (count): {sorted((out_html - src_html).items()) or 'none'}\n"
f" missing  (count): {sorted((src_md - out_md).items()) or 'none'}\n"
f" extra  (count): {sorted((out_md - src_md).items()) or 'none'}\n"
f" fix: restore the missing image refs in {out_chunk.name} from {src_chunk.name}"
)
if errors:
print("\n=== Image validation failed ===")
for e in errors:
print(e)
print()
return False
return True
def _check_generated_html_sanity(html_path):
"""Sanity-check generated HTML for malformed <img> tags. Returns False on problems.
Note: we deliberately do NOT flag `<img` in the rendered HTML — books that
discuss HTML in prose or code blocks legitimately render escaped `<img>` text,
and that's not a corruption signal. Real corruption produces a malformed
actual `<img>` tag, which the attribute-name check catches."""
try:
text = Path(html_path).read_text(encoding='utf-8')
except Exception as e:
print(f"ERROR: cannot read {html_path}: {e}")
return False
_, bad_attrs = _scan_img_tags(text)
if not bad_attrs:
return True
print(f"ERROR: image sanity check failed on {Path(html_path).name}")
for raw_tag, attr_name in bad_attrs:
print(f" - malformed <img>: {raw_tag}")
print(f" bad attribute name: '{attr_name}'")
print(
" fix: inspect output.md and the corresponding output_chunk*.md;\n"
" if alt text contains literal quotes, replace with curly quotes or HTML entity"
)
return False
# Try to import BeautifulSoup for TOC generation
try:
from bs4 import BeautifulSoup
BS4_AVAILABLE = True
except ImportError:
BS4_AVAILABLE = False
# Try to import markdown
try:
import markdown
MARKDOWN_AVAILABLE = True
except ImportError:
MARKDOWN_AVAILABLE = False
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# =============================================================================
# Language configuration — single source of truth for lang-dependent values
# =============================================================================
LANG_CONFIG = {
'zh': {
'lang_attr': 'zh-CN',
'font_family': "'FangSong', '仿宋', 'STFangSong', '华文仿宋', serif",
'font_family_ebook': '"FangSong", "FangSong_GB2312", "仿宋", "仿宋_GB2312", "STFangSong", "SimSun", serif',
'toc_label': '目录',
'pdf_font': 'FangSong',
},
'en': {
'lang_attr': 'en',
'font_family': "Georgia, 'Times New Roman', Times, serif",
'font_family_ebook': 'Georgia, "Times New Roman", Times, serif',
'toc_label': 'Contents',
'pdf_font': 'Georgia',
},
'ja': {
'lang_attr': 'ja',
'font_family': "'Hiragino Mincho ProN', 'Yu Mincho', 'MS Mincho', serif",
'font_family_ebook': '"Hiragino Mincho ProN", "Yu Mincho", "MS Mincho", serif',
'toc_label': '目次',
'pdf_font': 'Hiragino Mincho ProN',
},
'ko': {
'lang_attr': 'ko',
'font_family': "'Nanum Myeongjo', 'Batang', serif",
'font_family_ebook': '"Nanum Myeongjo", "Batang", serif',
'toc_label': '목차',
'pdf_font': 'Nanum Myeongjo',
},
'fr': {
'lang_attr': 'fr',
'font_family': "Georgia, 'Times New Roman', Times, serif",
'font_family_ebook': 'Georgia, "Times New Roman", Times, serif',
'toc_label': 'Table des matières',
'pdf_font': 'Georgia',
},
'de': {
'lang_attr': 'de',
'font_family': "Georgia, 'Times New Roman', Times, serif",
'font_family_ebook': 'Georgia, "Times New Roman", Times, serif',
'toc_label': 'Inhaltsverzeichnis',
'pdf_font': 'Georgia',
},
'es': {
'lang_attr': 'es',
'font_family': "Georgia, 'Times New Roman', Times, serif",
'font_family_ebook': 'Georgia, "Times New Roman", Times, serif',
'toc_label': 'Índice',
'pdf_font': 'Georgia',
},
}
# Default fallback for unknown languages
_DEFAULT_LANG_CONFIG = {
'lang_attr': 'en',
'font_family': "Georgia, 'Times New Roman', Times, serif",
'font_family_ebook': 'Georgia, "Times New Roman", Times, serif',
'toc_label': 'Contents',
'pdf_font': 'Georgia',
}
def get_lang_config(lang_code):
"""Get language config, falling back to defaults for unknown languages."""
return LANG_CONFIG.get(lang_code, _DEFAULT_LANG_CONFIG)
def load_config(temp_dir):
"""Load configuration from config.txt"""
config_file = os.path.join(temp_dir, 'config.txt')
if not os.path.exists(config_file):
print("Error: config.txt not found in temp directory.")
sys.exit(1)
config = {}
with open(config_file, 'r', encoding='utf-8') as f:
for line in f:
if '=' in line and not line.strip().startswith('#'):
key, value = line.strip().split('=', 1)
config[key] = value
return config
def natural_sort_key(text):
"""Natural sorting key for filenames with numbers"""
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', text)]
def _read_nonblank_translated_chunk(file_path):
"""Read a translated chunk and reject whitespace-only content."""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read().strip()
if not content:
raise ValueError("translated chunk is blank after trimming whitespace")
return content
# =============================================================================
# Step 4: Merge translated markdown files
# =============================================================================
def merge_markdown_files(temp_dir):
"""Merge all translated output files into output.md"""
print("=== Merging translated markdown files ===")
output_md = os.path.join(temp_dir, 'output.md')
# Always validate manifest, even if output.md exists (catch stale/corrupt outputs)
ok, ordered_files, warnings = validate_for_merge(temp_dir)
# Image structure validation runs unconditionally — bad chunks invalidate any cached output.md
if not _validate_chunk_images(temp_dir):
if os.path.exists(output_md):
print("Removing stale output.md (built from chunks that failed image validation)")
os.remove(output_md)
return False
if os.path.exists(output_md):
if not ok:
print(f"WARNING: output.md exists but manifest validation failed — deleting stale output.md")
os.remove(output_md)
else:
# Check if any output_chunk is newer than output.md (re-translated chunks)
output_md_mtime = os.path.getmtime(output_md)
newer_chunks = []
if ordered_files:
newer_chunks = [
os.path.basename(f) for f in ordered_files
if os.path.getmtime(f) > output_md_mtime
]
if newer_chunks:
print(f"Re-merging — {len(newer_chunks)} chunk(s) newer than output.md: {', '.join(newer_chunks[:5])}{'...' if len(newer_chunks) > 5 else ''}")
os.remove(output_md)
else:
print(f"Skipping merge - output.md already exists and is up to date")
return True
if not ok:
print("ERROR: Merge validation failed. Fix the issues above before merging.")
return False
if ordered_files is not None:
# Manifest-based merge
print(f"Merging {len(ordered_files)} translated files (manifest-ordered)")
merged_content = ""
for file_path in ordered_files:
try:
content = _read_nonblank_translated_chunk(file_path)
merged_content += content + "\n\n"
except Exception as e:
print(f"ERROR: Cannot merge {os.path.basename(file_path)}: {e}")
return False
else:
# Legacy fallback: glob-based merge (no manifest)
print("WARNING: No manifest.json found — using legacy glob-based merge.")
print(" For hash validation, re-run convert.py to generate manifest.json")
# Match chunk output files
output_files = glob.glob(os.path.join(temp_dir, 'output_chunk*.md'))
# Count original source files
original_files = glob.glob(os.path.join(temp_dir, 'chunk*.md'))
original_files = [f for f in original_files if not os.path.basename(f).startswith('output_')]
if not output_files:
print("Error: No translated markdown files found.")
return False
# Build expected output filename for each source file and verify 1:1 match
source_basenames = sorted(
[os.path.basename(f) for f in original_files],
key=natural_sort_key
)
expected_outputs = set(f"output_{name}" for name in source_basenames)
actual_outputs = set(os.path.basename(f) for f in output_files)
missing = expected_outputs - actual_outputs
orphaned = actual_outputs - expected_outputs
if missing or orphaned:
if missing:
print(f"ERROR: Missing translations for: {', '.join(sorted(missing, key=natural_sort_key))}")
if orphaned:
print(f"ERROR: Orphaned outputs (no matching source): {', '.join(sorted(orphaned, key=natural_sort_key))}")
return False
# Verify no empty output files
for fp in output_files:
if os.path.getsize(fp) == 0:
print(f"ERROR: Empty output file: {os.path.basename(fp)}")
return False
try:
_read_nonblank_translated_chunk(fp)
except Exception as e:
print(f"ERROR: Cannot merge {os.path.basename(fp)}: {e}")
return False
# Use source order to determine merge order (via expected output names)
output_files = [
os.path.join(temp_dir, f"output_{name}")
for name in source_basenames
]
print(f"Merging {len(output_files)} translated files (legacy glob)")
merged_content = ""
for file_path in output_files:
try:
content = _read_nonblank_translated_chunk(file_path)
merged_content += content + "\n\n"
except Exception as e:
print(f"ERROR: Cannot merge {os.path.basename(file_path)}: {e}")
return False
try:
with open(output_md, 'w', encoding='utf-8') as f:
f.write(merged_content)
file_size = os.path.getsize(output_md)
print(f"Merged into output.md ({file_size:,} bytes)")
return True
except Exception as e:
print(f"Error saving merged file: {e}")
return False
# =============================================================================
# Step 5: Convert markdown to HTML
# =============================================================================
def check_pandoc_available():
"""Check if pandoc is available"""
try:
subprocess.run(['pandoc', '--version'], capture_output=True, check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def convert_with_pandoc(md_file, html_file, title, lang_attr):
"""Convert markdown to HTML using pandoc"""
cmd = [
'pandoc', md_file, '-o', html_file,
'--standalone',
'--metadata', f'title={title}',
'--metadata', f'lang={lang_attr}',
'--from', 'markdown+smart+east_asian_line_breaks',
'--to', 'html5'
]
try:
subprocess.run(cmd, capture_output=True, text=True, check=True)
print("Converted with pandoc")
return True
except subprocess.CalledProcessError as e:
print(f"Pandoc conversion failed: {e.stderr}")
return False
def convert_with_python_markdown(md_file, html_file, title):
"""Convert markdown to HTML using python-markdown (fallback 1)"""
if not MARKDOWN_AVAILABLE:
return False
try:
with open(md_file, 'r', encoding='utf-8') as f:
md_content = f.read()
extensions = ['toc', 'tables', 'fenced_code', 'codehilite', 'nl2br']
md = markdown.Markdown(extensions=extensions)
html_content = md.convert(md_content)
full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<body>
{html_content}
</body>
</html>"""
with open(html_file, 'w', encoding='utf-8') as f:
f.write(full_html)
print("Converted with python-markdown (fallback)")
return True
except Exception as e:
print(f"python-markdown conversion failed: {e}")
return False
def convert_with_basic_regex(md_file, html_file, title):
"""Convert markdown to HTML using basic regex (fallback 2)"""
try:
with open(md_file, 'r', encoding='utf-8') as f:
md_content = f.read()
html_content = md_content
# Headers
html_content = re.sub(r'^#### (.*?)$', r'<h4>\1</h4>', html_content, flags=re.MULTILINE)
html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE)
html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE)
html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE)
# Bold and italic
html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content)
html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content)
html_content = re.sub(r'_(.*?)_', r'<em>\1</em>', html_content)
# Images — escape alt and src so quotes in alt text don't break the tag
def _md_img_to_html(m):
alt = _html_lib.escape(m.group(1), quote=True)
src = _html_lib.escape(m.group(2), quote=True)
return f'<img src="{src}" alt="{alt}">'
html_content = re.sub(r'!\[([^\]]*)\]\(([^)]*)\)', _md_img_to_html, html_content)
# Links
html_content = re.sub(r'\[([^\]]*)\]\(([^)]*)\)', r'<a href="\2">\1</a>', html_content)
# Lists and paragraphs
lines = html_content.split('\n')
result_lines = []
in_list = False
for line in lines:
stripped = line.strip()
if stripped.startswith('- '):
if not in_list:
result_lines.append('<ul>')
in_list = 'ul'
item = stripped[2:]
result_lines.append(f'<li>{item}</li>')
elif re.match(r'^\d+\. ', stripped):
if not in_list:
result_lines.append('<ol>')
in_list = 'ol'
item = re.sub(r'^\d+\. ', '', stripped)
result_lines.append(f'<li>{item}</li>')
else:
if in_list:
result_lines.append(f'</{in_list}>')
in_list = False
if stripped and not stripped.startswith('<'):
result_lines.append(f'<p>{line}</p>')
else:
result_lines.append(line)
if in_list:
result_lines.append(f'</{in_list}>')
html_content = '\n'.join(result_lines)
# Page separators
html_content = re.sub(r'<p>---</p>', '<div class="page-separator"></div>', html_content)
full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<body>
{html_content}
</body>
</html>"""
with open(html_file, 'w', encoding='utf-8') as f:
f.write(full_html)
print("Converted with basic regex (fallback 2)")
return True
except Exception as e:
print(f"Basic regex conversion failed: {e}")
return False
def apply_template_to_html(html_content, template_file, output_file, title, lang_cfg, author=None):
"""Apply a template to HTML content with language-aware substitutions"""
if not template_file or not os.path.exists(template_file):
print(f"Warning: Template {template_file} not found")
return False
try:
with open(template_file, 'r', encoding='utf-8') as f:
template_content = f.read()
if '$body$' in template_content:
full_html = template_content.replace('$body$', html_content)
elif '{{content}}' in template_content:
full_html = template_content.replace('{{content}}', html_content)
else:
if '</body>' in template_content:
full_html = template_content.replace('</body>', f'{html_content}\n</body>')
else:
full_html = template_content + html_content
# Replace all template placeholders
full_html = full_html.replace('$title$', title)
full_html = full_html.replace('$lang$', lang_cfg['lang_attr'])
full_html = full_html.replace('$body_font$', lang_cfg['font_family'])
full_html = full_html.replace('$toc_label$', lang_cfg['toc_label'])
# Inject author meta tag into <head> so calibre_html_publish.py can extract it
if author:
author_meta = f'<meta name="author" content="{author}">'
if '<head>' in full_html or '<head ' in full_html:
full_html = re.sub(
r'(<head[^>]*>)',
r'\1\n ' + author_meta,
full_html,
count=1,
flags=re.IGNORECASE
)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(full_html)
return True
except Exception as e:
print(f"Error applying template: {e}")
return False
def process_html_separators(html_file):
"""Process page separators in HTML"""
try:
with open(html_file, 'r', encoding='utf-8') as f:
content = f.read()
content = re.sub(r'<hr\s*/?>', '<div class="page-separator"></div>', content)
content = re.sub(r'<p>\s*---\s*</p>', '<div class="page-separator"></div>', content)
with open(html_file, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
print(f"Error processing separators: {e}")
def convert_md_to_html(temp_dir, title, lang_cfg, author=None):
"""Convert output.md to HTML with templates"""
print("=== Converting markdown to HTML ===")
md_file = os.path.join(temp_dir, 'output.md')
if not os.path.exists(md_file):
print("Error: output.md not found.")
return False
book_doc_file = os.path.join(temp_dir, 'book_doc.html')
# Skip HTML generation if book_doc.html exists and is newer than output.md
if os.path.exists(book_doc_file):
if os.path.getmtime(book_doc_file) > os.path.getmtime(md_file):
if _check_generated_html_sanity(book_doc_file):
print("Skipping HTML generation - book_doc.html is up to date")
return True
print("Stale book_doc.html failed image sanity — regenerating")
os.remove(book_doc_file)
else:
print("Re-generating HTML - output.md is newer")
temp_html_file = os.path.join(temp_dir, 'output.html')
# Try pandoc -> python-markdown -> basic regex
success = False
if check_pandoc_available():
success = convert_with_pandoc(md_file, temp_html_file, title, lang_cfg['lang_attr'])
if not success:
success = convert_with_python_markdown(md_file, temp_html_file, title)
if not success:
success = convert_with_basic_regex(md_file, temp_html_file, title)
if not success:
print("Error: All markdown-to-HTML converters failed")
return False
process_html_separators(temp_html_file)
# Extract body content
try:
with open(temp_html_file, 'r', encoding='utf-8') as f:
html_content = f.read()
except Exception as e:
print(f"Error reading HTML file: {e}")
return False
body_match = re.search(r'<body[^>]*>(.*?)</body>', html_content, re.DOTALL | re.IGNORECASE)
body_content = body_match.group(1).strip() if body_match else html_content
# Generate book_doc.html with ebook template
template_ebook = os.path.join(SCRIPT_DIR, 'template_ebook.html')
book_doc_file = os.path.join(temp_dir, 'book_doc.html')
apply_template_to_html(body_content, template_ebook, book_doc_file, title, lang_cfg, author)
# Generate book.html with web template
template_web = os.path.join(SCRIPT_DIR, 'template.html')
book_file = os.path.join(temp_dir, 'book.html')
apply_template_to_html(body_content, template_web, book_file, title, lang_cfg, author)
if not _check_generated_html_sanity(book_doc_file):
return False
if not _check_generated_html_sanity(book_file):
return False
print(f"Generated: output.html, book_doc.html, book.html")
return True
# =============================================================================
# Step 6: Add TOC
# =============================================================================
def generate_heading_id(text, existing_ids):
"""Generate unique ID for heading"""
base_id = re.sub(r'[^\w\s-]', '', text.lower())
base_id = re.sub(r'[-\s]+', '-', base_id)
base_id = base_id.strip('-')
if not base_id:
base_id = 'heading'
heading_id = base_id
counter = 1
while heading_id in existing_ids:
heading_id = f"{base_id}-{counter}"
counter += 1
return heading_id
def generate_simple_toc_html(toc_data):
"""Generate simple HTML for table of contents"""
if not toc_data:
return ""
toc_html = '<ul>\n'
current_level = 1
for item in toc_data:
level = item['level']
text = item['text']
heading_id = item['id']
if level > current_level:
while current_level < level:
toc_html += '<li><ul>\n'
current_level += 1
elif level < current_level:
while current_level > level:
toc_html += '</ul></li>\n'
current_level -= 1
toc_html += f'<li><a href="#{heading_id}">{text}</a></li>\n'
while current_level > 1:
toc_html += '</ul></li>\n'
current_level -= 1
toc_html += '</ul>\n'
return toc_html
def insert_toc_with_bs4(html_file):
"""Insert TOC using BeautifulSoup"""
try:
with open(html_file, 'r', encoding='utf-8') as f:
html_content = f.read()
except Exception as e:
print(f"Error reading HTML file: {e}")
return False
soup = BeautifulSoup(html_content, 'html.parser')
toc_data = []
existing_ids = []
for heading in soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']):
level = int(heading.name[1])
text = heading.get_text().strip()
if not text:
continue
heading_id = generate_heading_id(text, existing_ids)
existing_ids.append(heading_id)
heading['id'] = heading_id
toc_data.append({'level': level, 'text': text, 'id': heading_id})
if not toc_data:
print("No headings found for TOC")
return False
toc_html = generate_simple_toc_html(toc_data)
toc_content_div = soup.find('div', class_='toc-content')
if toc_content_div:
toc_content_div.clear()
toc_soup = BeautifulSoup(toc_html, 'html.parser')
toc_content_div.append(toc_soup)
print(f"TOC inserted ({len(toc_data)} headings)")
else:
print("Warning: .toc-content div not found, TOC not inserted")
return False
try:
with open(html_file, 'w', encoding='utf-8') as f:
f.write(str(soup))
return True
except Exception as e:
print(f"Error saving HTML file: {e}")
return False
def insert_toc_with_regex(html_file):
"""Insert TOC using regex (fallback)"""
try:
with open(html_file, 'r', encoding='utf-8') as f:
html_content = f.read()
except Exception as e:
print(f"Error reading HTML file: {e}")
return False
heading_pattern = r'<(h[1-6])(?:[^>]*)>(.*?)</\1>'
headings = re.findall(heading_pattern, html_content, re.IGNORECASE | re.DOTALL)
if not headings:
print("No headings found for TOC")
return False
toc_html = '<ul>\n'
for i, (tag, text) in enumerate(headings):
level = int(tag[1])
clean_text = re.sub(r'<[^>]+>', '', text).strip()
heading_id = f"heading-{i+1}"
old_heading = f'<{tag}>{text}</{tag}>'
new_heading = f'<{tag} id="{heading_id}">{text}</{tag}>'
html_content = html_content.replace(old_heading, new_heading, 1)
if level > 1:
for _ in range(level - 1):
toc_html += ' '
toc_html += f'<li><a href="#{heading_id}">{clean_text}</a></li>\n'
toc_html += '</ul>\n'
toc_content_pattern = r'(<div[^>]*class="toc-content[^"]*"[^>]*>).*?(</div>)'
if re.search(toc_content_pattern, html_content, re.DOTALL):
html_content = re.sub(
toc_content_pattern,
r'\1' + toc_html + r'\2',
html_content,
flags=re.DOTALL
)
print(f"TOC inserted ({len(headings)} headings)")
else:
print("Warning: .toc-content div not found")
return False
try:
with open(html_file, 'w', encoding='utf-8') as f:
f.write(html_content)
return True
except Exception as e:
print(f"Error saving HTML file: {e}")
return False
def add_toc(temp_dir):
"""Add TOC to book.html"""
print("=== Adding Table of Contents ===")
book_file = os.path.join(temp_dir, 'book.html')
if not os.path.exists(book_file):
print("Warning: book.html not found, skipping TOC")
return False
if BS4_AVAILABLE:
return insert_toc_with_bs4(book_file)
else:
return insert_toc_with_regex(book_file)
# =============================================================================
# Step 7: Generate DOCX/EPUB/PDF with error transparency
# =============================================================================
def generate_format(html_file, temp_dir, output_ext, lang_attr, cover=None):
"""Generate a specific format using calibre_html_publish.py"""
output_file = os.path.join(temp_dir, f"book{output_ext}")
cover = cover if output_ext == '.epub' else None
if cover and not os.path.isfile(cover):
print(f"Cover image not found: {cover}")
return None
if os.path.exists(output_file):
output_mtime = os.path.getmtime(output_file)
# Check if source HTML is newer
html_newer = os.path.getmtime(html_file) > output_mtime
# Check if any image asset is newer (Calibre embeds these)
images_newer = False
images_dir = os.path.join(temp_dir, 'images')
if os.path.isdir(images_dir):
for img in os.listdir(images_dir):
img_path = os.path.join(images_dir, img)
if os.path.isfile(img_path) and os.path.getmtime(img_path) > output_mtime:
images_newer = True
break
cover_newer = bool(cover and os.path.getmtime(cover) > output_mtime)
if not html_newer and not images_newer and not cover_newer:
file_size = os.path.getsize(output_file)
print(f"Skipping {output_ext} - already exists and up to date ({file_size:,} bytes)")
return output_file
else:
reasons = []
if html_newer:
reasons.append("source HTML changed")
if images_newer:
reasons.append("image assets changed")
if cover_newer:
reasons.append("cover image changed")
print(f"Rebuilding {output_ext} - {', '.join(reasons)}")
publish_script = os.path.join(SCRIPT_DIR, "calibre_html_publish.py")
if not os.path.exists(publish_script):
print(f"calibre_html_publish.py not found at: {publish_script}")
return None
try:
cmd = ["python3", publish_script, html_file, "-o", output_file, "--lang", lang_attr]
if cover:
cmd.extend(["--cover", cover])
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
if os.path.exists(output_file):
file_size = os.path.getsize(output_file)
return output_file
else:
print(f"Failed to generate {output_ext}")
if result.stdout:
print(f" stdout: {result.stdout[-500:]}")
return None
except subprocess.CalledProcessError as e:
print(f"Failed to generate {output_ext}")
if e.stdout:
print(f" stdout: {e.stdout[-500:]}")
if e.stderr:
print(f" stderr: {e.stderr[-500:]}")
return None
except Exception as e:
print(f"Error generating {output_ext}: {e}")
return None
def generate_formats(temp_dir, lang_attr, cover=None):
"""Generate DOCX, EPUB, and PDF with result summary"""
print("=== Generating output formats ===")
html_file = os.path.join(temp_dir, "book_doc.html")
if not os.path.exists(html_file):
html_files = glob.glob(os.path.join(temp_dir, "*.html"))
if html_files:
html_file = max(html_files, key=os.path.getmtime)
else:
print("No HTML files found for format generation")
return
results = {}
for ext in ['.docx', '.epub', '.pdf']:
result = generate_format(html_file, temp_dir, ext, lang_attr, cover=cover)
if result:
file_size = os.path.getsize(result)
results[ext] = ('OK', f"{file_size:,} bytes")
else:
results[ext] = ('FAILED', '')
# Print summary table
print("\nFormat results:")
has_failures = False
for ext, (status, detail) in results.items():
if status == 'OK':
print(f" {ext}: {status} ({detail})")
else:
print(f" {ext}: {status}")
has_failures = True
return not has_failures
def _validate_export_name(name):
"""Validate an export filename stem. Keep aliases inside temp_dir."""
if not name or not name.strip():
raise ValueError("--export-name must not be empty")
if '\x00' in name or '/' in name or '\\' in name:
raise ValueError("--export-name must be a filename stem, not a path")
return name.strip()
def export_named_aliases(temp_dir, export_name):
"""Copy canonical outputs to optional user-facing filenames.
Canonical artifacts remain untouched. The alias names use export_name as a
filename stem, with book_doc.html receiving a _doc suffix to avoid colliding
with the web HTML alias.
"""
stem = _validate_export_name(export_name)