srm-report 0.0.0-stage → 1.0.0

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,105 @@
1
+ # Rules and lessons
2
+
3
+ These come from real report sessions and the signed reports compared against them. The attached template overrides the formatting defaults in section 1. The corrections in section 2 came from the user and apply to every report.
4
+
5
+ ## 1. Formatting defaults (21CSP302L / 21CSE734P template)
6
+
7
+ | Item | Rule |
8
+ |---|---|
9
+ | Font | Times New Roman throughout (code may use Courier New) |
10
+ | Chapter number and title | 16pt bold caps, centred, every chapter on a new page |
11
+ | Sub-heading (1.1) | 16pt bold Title Case |
12
+ | Sub-sub-heading (1.1.1) | 12pt bold capitals |
13
+ | Body | 12pt, 1.5 spacing, justified, space between paragraphs, at most 12 lines a paragraph |
14
+ | Page | A4, left margin 1 inch, right 0.5 inch |
15
+ | Figures | "Fig X.Y" centred, caption below |
16
+ | Tables | "Table X.Y" centred, caption above |
17
+ | Page numbers | Bottom centre. Lower roman on front matter, arabic from Chapter 1 (page 1) to the end |
18
+ | Citations | [1], [4,5], [7-10], numbered by first use, listed in the same order, at least 15 |
19
+ | Length | At least 30 pages, excluding code and screenshots |
20
+ | Plagiarism | Turnitin through the guide, 10% or less; publication proofs where publications are claimed |
21
+
22
+ Front order: cover, Own Work Declaration, Bonafide, Acknowledgements, Abstract, Table of Contents, List of Figures, List of Tables, Abbreviations.
23
+
24
+ Older departmental sheets online mention 15% plagiarism and other margins, and SRM NCR uses different margins again. They are not sources for this campus. Trust the template.
25
+
26
+ ### Reading template notes
27
+
28
+ - A note such as `<Font Size 18> <1.5 line spacing>` is an instruction for the text above it. Apply it, then delete it. `apply_cover_notes()` does this for the cover. `<Font Style ... Bold>` at the top of the cover covers the whole cover.
29
+ - `<Font Style Times New Roman, Font Size 14>` near the abstract is not followed in the signed report or in the accepted report: abstract heading 16pt, body 12pt. Keep 12pt unless the user says otherwise.
30
+ - A note's paragraph may also hold the logo, so check for an image before deleting it.
31
+ - Negative character spacing in template runs squeezes words together on the page ("requirementsfor"). The text is fine, so the fix is removing the spacing.
32
+
33
+ ### Template errors to fix quietly, and mention
34
+
35
+ Chapter numbering jumps from 3 to 6; "2.5" used twice; "EXECTION"; "compiles with" and "Compiled with" (should be "complies" and "Complied"); no full stop on the last acknowledgement line.
36
+
37
+ ## 2. Corrections from the user
38
+
39
+ **Fixed pages**
40
+ - Template pages must be exact ("ye wala page exact bana"). Fill a copy; never recreate them.
41
+ - Bonafide fits on one page. Signature block centred in its box. Each name has its designation, with the same line count in each column.
42
+ - Footer is the page number only, no rule line.
43
+ - A page border only if asked.
44
+
45
+ **Names and labels**
46
+ - The label is GUIDE / CO-GUIDE, not SUPERVISOR. The senior designation is GUIDE and goes first (Associate before Assistant).
47
+ - Get faculty spelling and designation right, and confirm them with the user. All faculty are named everywhere.
48
+ - Faculty Advisors are named individually, in the same order as the students.
49
+ - Names are bold in the acknowledgements.
50
+ - The template's Vice-Chancellor, Dean and Chairperson names and its "his/her" may be out of date, or wrong for the person now in the post. Ask the user to confirm them.
51
+
52
+ **Voice**
53
+ - "We", never "I", everywhere, including the declaration and bonafide ("under our supervision", "best of our knowledge"). User stories in a backlog stay in the first person.
54
+
55
+ **Course and degree**
56
+ - The course code from the user or title sheet beats the template (21CSE734P vs 21CSP302L), and must be identical on cover and bonafide.
57
+ - Core CSE drops the "with specialization in" line. Never print `[SPECIALIZATION NAME]`; a signed report did, so don't copy mistakes from examples.
58
+ - Replace degree and department everywhere the same way. The template says Computational Intelligence, which may not be the user's department.
59
+
60
+ **Tables and contents**
61
+ - No table crosses a page ("pages mei ek bhi table katna nahi"). White space at a page end is acceptable.
62
+ - TOC filled with real page numbers. Chapter lines bold, sub-topics not.
63
+
64
+ **Appendices**
65
+ - Remove placeholder appendices when asked ("appendix b hata de"). With no publications, drop Conference and Journal and re-letter the rest.
66
+ - Code listings longer than a page are split at logical boundaries into A.2(a), (b), (c). Keep each to about 55 lines; 74 was still too long.
67
+
68
+ **Truth**
69
+ - "saare rules updated hai na? real hai na sab?" Everything must be real. See `content.md`.
70
+
71
+ **Images**
72
+ - Don't crop preparation screenshots ("crop nahi kar").
73
+ - A photo goes in only after checking it matches the session and date.
74
+ - Take screenshots from a separate fixture-mode instance, never the user's live backend or quota.
75
+ - Figures: white background, Times New Roman, print-friendly.
76
+
77
+ **Before printing**
78
+ - Print from the PDF, not the .docx.
79
+ - Checklist for the user: Turnitin report, HoD name, month on the cover, hand signatures.
80
+ - Compare against a signed report if the user has one.
81
+
82
+ ## 3. Failures already hit, and what prevents them
83
+
84
+ | Symptom | Cause | Prevention |
85
+ |---|---|---|
86
+ | Word says the file is corrupt | XML children out of schema order | `schema_order()` runs in `save()` |
87
+ | Corrupt file after copying a code box | Copied after trimming it | Keep a pristine deepcopy first |
88
+ | A word jumps to the next page | Cloned paragraph carried a `w:sectPr` | `clone_after` strips it |
89
+ | "DepartmentAssistant Professor," | Stale runs left behind after editing one run | `set_text` removes other runs |
90
+ | Three page numbers in one footer | Looping over a linked footer | Unlink and clear the footer first |
91
+ | TOC lists cover lines | Cover uses Heading styles, and a `\o` TOC picks them up | Custom styles and `TOC \t` |
92
+ | Bold TOC chapter lines also bold the List of Figures | Lists share toc 1 | Lists use toc 4 |
93
+ | Title printed twice on the cover | A broad `replace()` hit the already filled cover | Edit the one paragraph, not the whole document |
94
+ | Logo missing from the cover | Logo sat in a note paragraph that got deleted | Check for images first; the checker now tests this |
95
+ | Blank page before Abstract | Empty lines before the cut spilled over | `cut_from` trims them |
96
+ | Bonafide spills to a second page | Note lines kept as blanks | Delete them; the checker now tests this |
97
+ | One name alone on a page | The author list overflowed the acknowledgements | Trim blank lines above it; the checker now flags stray-line pages |
98
+ | Acknowledgements starts on the bonafide page | Template reaches the next page with blank lines alone | `start_new_page()` gives a real break; the checker tests main headings |
99
+ | Paragraphs on one page have different widths | Removed `w:ind`; the page depends on left and right indents | `tidy_paragraphs()` removes only first-line offsets |
100
+ | Checker reports results for the previous build | Word pass failed, and the old PDF was still there | `word_pass.ps1` deletes the old PDF first |
101
+ | "Device or resource busy", `~$` file | The user has the file open in Word | Work on a scratch copy; never kill Word |
102
+ | cp1252 errors in the console | Windows default encoding | Run Python with `-X utf8` |
103
+ | Shell errors on long inline scripts | Quoting in heredocs | Write the script to a file first |
104
+ | Can't read a source PDF through a web fetch | Binary file | Save it and read it with PyMuPDF |
105
+ | Word windows popping up | Visible automation | Keep `Visible=false`; never kill by window title |
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """Check a built report against the rules. Exit code 1 if anything FAILs.
3
+
4
+ usage: python -X utf8 check_report.py report.docx report.pdf [checks.json]
5
+
6
+ checks.json (all optional; take the values from the template's rules, not from memory):
7
+ {"must_contain": ["names, reg numbers, course code"], "must_not_contain": ["SUPERVISOR", "old spelling"],
8
+ "min_refs": 15, "min_pages": 30, "body_pt": 12, "layout": "layout.json", "font": "Times New Roman",
9
+ "max_para_chars": 1250, "allow_first_person": false}
10
+ """
11
+ import json, re, sys
12
+ from collections import Counter
13
+ import docx, fitz
14
+ from docx.oxml.ns import qn
15
+
16
+ LEFTOVER = re.compile(r"<<[^<>]*>>|<\s*Font[^<>]*>|<\s*[lI1]\.5 line spacing>|<Italic>|\.{6,}(?=\s*$)|STUDENT ?\d|REG NUM|\(GUIDE NAME|\(Designation, Department\)|"
17
+ r"SPECIALIZATION NAME|PROJECT TITLE|TITLE OF THE (?:REPORT|COMMUNITY)|Name of the [Ss]tudent|Dr\. ?ABCD|Specimen|Update this field|Error! |No table of (?:contents|figures) entries|Subtitle 1")
18
+ FIRST = re.compile(r"(?<![\w/])(I|my|My|me|mine|myself)(?![\w/.-])")
19
+ ROMAN_LABEL = re.compile(r"(Sprint|SPRINT|Phase|Part|Type|Review|Table|Class|Stage|Level|Tier|Unit|Module)\s+I\b")
20
+ results = []
21
+
22
+
23
+ def report(level, name, detail=""):
24
+ results.append(level)
25
+ print(f"{level:4s} {name}{': ' + detail if detail else ''}")
26
+
27
+
28
+ def spans(pdf):
29
+ for p in pdf:
30
+ for b in p.get_text("dict")["blocks"]:
31
+ for l in b.get("lines", []):
32
+ for sp in l["spans"]:
33
+ if sp["text"].strip():
34
+ yield sp
35
+
36
+
37
+ def main(docx_path, pdf_path, cfg):
38
+ d = docx.Document(docx_path)
39
+ body = d.element.body
40
+ pdf = fitz.open(pdf_path)
41
+ paras = [("".join(t.text or "" for t in p.iter(qn("w:t"))), p) for p in body.iter(qn("w:p"))]
42
+ style = lambda p: (p.xpath("./w:pPr/w:pStyle/@w:val") or [""])[0]
43
+ alltext = "\n".join(t for t, _ in paras)
44
+ pdf_text = [p.get_text(sort=True) for p in pdf] # sort: Word writes the footer first in the content stream
45
+ pdf_all = "\n".join(pdf_text)
46
+
47
+ hits = sorted({m.group(0) for m in LEFTOVER.finditer(pdf_all)})
48
+ report("FAIL" if hits else "ok", "template placeholders and specimen text left", ", ".join(hits[:12]))
49
+
50
+ flat = pdf_all.replace("\n", " ").lower()
51
+ for s in cfg.get("must_contain", []):
52
+ if s.lower() not in flat and s.lower() not in alltext.lower():
53
+ report("FAIL", "missing from report", s)
54
+ for s in cfg.get("must_not_contain", []):
55
+ if s.lower() in alltext.lower() or s.lower() in pdf_all.lower():
56
+ report("FAIL", "must not appear", s)
57
+
58
+ if not cfg.get("allow_first_person"):
59
+ fp = []
60
+ for t, p in paras:
61
+ if style(p) in ("RptCode", "RptRef") or t.strip() == "I": # a lone I is a Sprint I table cell
62
+ continue
63
+ for m in FIRST.finditer(t):
64
+ ctx = t[max(0, m.start() - 25):m.end() + 25]
65
+ if m.group(1) == "I" and ROMAN_LABEL.search(ctx):
66
+ continue
67
+ if re.match(r"\s*As an? ", t): # user stories are written in the first person on purpose
68
+ continue
69
+ fp.append(ctx.strip())
70
+ report("WARN" if fp else "ok", "first person ('I', 'my') outside code: team reports say 'We'", " | ".join(fp[:6]))
71
+
72
+ want = cfg.get("font", "Times New Roman").replace(" ", "").lower()
73
+ fonts = Counter(); sizes = Counter()
74
+ for sp in spans(pdf):
75
+ fonts[sp["font"].split("+")[-1].split(",")[0].split("-")[0]] += len(sp["text"])
76
+ if len(sp["text"].strip()) > 3:
77
+ sizes[round(sp["size"] * 2) / 2] += len(sp["text"])
78
+ other = {k: v for k, v in fonts.items() if want not in k.lower().replace("psmt", "").replace("ps", "") and not re.search("courier|symbol|wingdings", k, re.I)}
79
+ total = sum(fonts.values()) or 1
80
+ report("WARN" if sum(other.values()) > total * 0.002 else "ok", f"font is {cfg.get('font', 'Times New Roman')} (code may use Courier)", str(other) if other else "")
81
+ top = sizes.most_common(1)[0][0] if sizes else 0
82
+ if "body_pt" in cfg:
83
+ report("ok" if top == cfg["body_pt"] else "FAIL", f"body text size is {cfg['body_pt']}pt", f"most common size {top}pt")
84
+
85
+ # figures and tables
86
+ fig, tab, prob = [], [], []
87
+ kids = list(body.iterchildren())
88
+ has_pic = lambda e: e.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}blip") is not None
89
+ for i, e in enumerate(kids):
90
+ if e.tag != qn("w:p"):
91
+ continue
92
+ t = "".join(x.text or "" for x in e.iter(qn("w:t")))
93
+ m = re.match(r"(?:Fig|Figure)\.?\s*([A-Z0-9]+)\.(\d+)\s*:", t)
94
+ if m and style(e) == "FigCaption":
95
+ fig.append((m.group(1), int(m.group(2))))
96
+ if i == 0 or not has_pic(kids[i - 1]):
97
+ prob.append(f"{t[:30]} is not directly below a figure")
98
+ m = re.match(r"Table\s*([A-Z0-9]+)\.(\d+)\s*:", t)
99
+ if m and style(e) == "TabCaption":
100
+ tab.append((m.group(1), int(m.group(2))))
101
+ if i + 1 >= len(kids) or kids[i + 1].tag != qn("w:tbl"):
102
+ prob.append(f"{t[:30]} is not directly above a table")
103
+ for name, seq in (("Fig", fig), ("Table", tab)):
104
+ last = {}
105
+ for ch, n in seq:
106
+ if n != last.get(ch, 0) + 1:
107
+ prob.append(f"{name} {ch}.{n} breaks the numbering")
108
+ last[ch] = n
109
+ report("FAIL" if prob else "ok", f"captions: {len(fig)} figures below, {len(tab)} tables above, numbered per chapter", "; ".join(prob[:6]))
110
+
111
+ # citations
112
+ ref_at = max((i for i, (t, _) in enumerate(paras) if t.strip().upper() == "REFERENCES"), default=len(paras))
113
+ ref_paras = [t for t, _ in paras[ref_at:] if re.match(r"\[\d+\]", t)]
114
+ ref_nums = [int(re.match(r"\[(\d+)\]", t).group(1)) for t in ref_paras]
115
+ order, seen = [], set()
116
+ for t, p in paras[:ref_at]:
117
+ if style(p) == "RptCode":
118
+ continue
119
+ for m in re.finditer(r"\[(\d+(?:\s*[,-]\s*\d+)*)\]", t):
120
+ for part in m.group(1).split(","):
121
+ part = part.strip()
122
+ a, b = part.split("-") if "-" in part else (part, part)
123
+ for n in range(int(a), int(b) + 1):
124
+ if n not in seen:
125
+ seen.add(n); order.append(n)
126
+ prob = []
127
+ if ref_nums != list(range(1, len(ref_nums) + 1)):
128
+ prob.append("reference list is not numbered 1..N")
129
+ if order != sorted(order):
130
+ prob.append("citations do not first appear in order 1, 2, 3...")
131
+ un = sorted(set(ref_nums) - seen); no = sorted(seen - set(ref_nums))
132
+ if un:
133
+ prob.append(f"never cited: {un[:10]}")
134
+ if no:
135
+ prob.append(f"cited but not listed: {no[:10]}")
136
+ mn = cfg.get("min_refs", 0)
137
+ if len(ref_nums) < mn:
138
+ prob.append(f"only {len(ref_nums)} references, template asks for {mn}")
139
+ report("FAIL" if prob else "ok", f"citations: {len(ref_nums)} references", "; ".join(prob))
140
+
141
+ limit = cfg.get("max_para_chars", 1250)
142
+ long_p = [t[:50] for t, p in paras if style(p) in ("RptBody", "") and len(t) > limit]
143
+ report("WARN" if long_p else "ok", f"paragraphs near the 12-line limit (over {limit} characters)", " | ".join(long_p[:5]))
144
+
145
+ # layout from the PDF
146
+ def foot(pg):
147
+ lines = [x.strip() for x in pg.get_text(sort=True).splitlines() if x.strip()]
148
+ return lines[-1] if lines else ""
149
+
150
+ chap = [i for i, t in enumerate(pdf_text) if re.search(r"^\s*CHAPTER\s+\d+\s*$", t, re.M) and "TABLE OF CONTENTS" not in t and not re.search(r"\.{5,}", t)]
151
+ late = [i + 1 for i in chap if not re.match(r"\s*CHAPTER\s+\d+", pdf_text[i])]
152
+ report("WARN" if late else "ok", f"{len(chap)} chapters each start at the top of a new page", f"physical pages {late}" if late else "")
153
+ if chap:
154
+ first = chap[0]
155
+ seq = [foot(pdf[i]) for i in range(first, len(pdf))]
156
+ bad = [i for i, s in enumerate(seq) if s != str(i + 1)]
157
+ report("FAIL" if bad else "ok", "arabic page numbers start at 1 on Chapter 1 and run to the end", f"at position {bad[0] + 1} the footer reads {seq[bad[0]]!r}" if bad else "")
158
+ rom = [i + 1 for i in range(1, first) if not re.fullmatch(r"[ivxlc]+", foot(pdf[i]))]
159
+ report("WARN" if rom else "ok", "front pages show roman numerals", f"physical pages without one: {rom}" if rom else "")
160
+ blank = [i + 1 for i, p in enumerate(pdf) if len(pdf_text[i].strip()) < 5 and not p.get_images()]
161
+ report("FAIL" if blank else "ok", "no blank pages", str(blank) if blank else "")
162
+ # One stray line on its own page: a fixed page overflowed, as an author list did in testing.
163
+ stub = [i + 1 for i, p in enumerate(pdf) if 5 <= len(re.sub(r"\s+", "", pdf_text[i])) < 60 and not p.get_images()]
164
+ report("FAIL" if stub else "ok", "no page holding only a stray line", str(stub) if stub else "")
165
+ if cfg.get("min_pages"):
166
+ report("ok" if len(pdf) >= cfg["min_pages"] else "FAIL", f"at least {cfg['min_pages']} pages", f"{len(pdf)} pages (the template excludes code and screenshots from this count)")
167
+
168
+ if cfg.get("layout"):
169
+ lay = json.load(open(cfg["layout"], encoding="utf-8-sig"))
170
+ tabs = lay.get("tables") or []
171
+ tabs = tabs if isinstance(tabs, list) else [tabs]
172
+ sp = [t for t in tabs if t["start"] != t["end"]]
173
+ report("FAIL" if sp else "ok", f"no table breaks across pages ({len(tabs)} tables)", "; ".join(f"pages {t['start']}-{t['end']}: {t['text'][:30]}" for t in sp[:6]))
174
+
175
+ # Main headings open a page. A template that paginates with blank lines loses this silently when text moves.
176
+ misplaced = []
177
+ for h in ("ACKNOWLEDGEMENTS", "ABSTRACT", "TABLE OF CONTENTS", "LIST OF FIGURES", "LIST OF TABLES", "ABBREVIATIONS", "REFERENCES"):
178
+ for i, t in enumerate(pdf_text):
179
+ lines = [x.strip() for x in t.splitlines() if x.strip()]
180
+ if h in lines and lines[0] != h:
181
+ misplaced.append(f"{h} on physical page {i + 1}")
182
+ report("FAIL" if misplaced else "ok", "main headings start at the top of a page", "; ".join(misplaced))
183
+
184
+ # Fixed pages that must stay whole. Text can overflow silently, so look at the rendered pages.
185
+ pairs = [["BONAFIDE CERTIFICATE", "EXAMINER 2"]] + cfg.get("same_page", [])
186
+ for a, b in pairs:
187
+ on_a = [i for i, t in enumerate(pdf_text) if a in t]; on_b = [i for i, t in enumerate(pdf_text) if b in t]
188
+ if on_a or on_b:
189
+ ok = bool(set(on_a) & set(on_b))
190
+ report("ok" if ok else "FAIL", f"'{a}' and '{b}' are on one page", "" if ok else f"physical pages {[x + 1 for x in on_a]} and {[x + 1 for x in on_b]}")
191
+ if not pdf[0].get_images():
192
+ report("FAIL", "cover has its logo", "no image on page 1")
193
+ else:
194
+ report("ok", "cover has its logo")
195
+ in_cells = [s for s in body.iter(qn("w:sectPr")) if any(a.tag == qn("w:tc") for a in s.iterancestors())]
196
+ report("FAIL" if in_cells else "ok", "no stray section breaks inside tables", str(len(in_cells)) if in_cells else "")
197
+ print(f"\n{results.count('FAIL')} fail, {results.count('WARN')} warn, {results.count('ok')} ok, {len(pdf)} pages")
198
+ return 1 if "FAIL" in results else 0
199
+
200
+
201
+ if __name__ == "__main__":
202
+ if len(sys.argv) < 3:
203
+ sys.exit(__doc__)
204
+ cfg = json.load(open(sys.argv[3], encoding="utf-8")) if len(sys.argv) > 3 else {}
205
+ sys.exit(main(sys.argv[1], sys.argv[2], cfg))
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """Read an SRM report template (or a finished report) and write down everything a builder must know.
3
+
4
+ usage: python -X utf8 inspect_template.py <template.docx> <outdir>
5
+
6
+ Writes to <outdir>:
7
+ structure.txt every body element: index, type, style, size, bold, align, markers, text
8
+ rules.txt sentences that state a rule, inline notes like <Font Size 18>, and placeholders to fill
9
+ sections.txt each section's page size, margins, numbering and footer text
10
+ Prints a short summary. Read all three files before planning the report.
11
+ """
12
+ import os, re, sys
13
+ from collections import Counter
14
+ import docx
15
+ from docx.oxml.ns import qn
16
+ from docx.table import Table
17
+ from docx.text.paragraph import Paragraph
18
+
19
+ RULE_WORDS = re.compile(
20
+ r"\b(must|should|shall|minimum|maximum|mandatory|at least|not more than|font|margin|spacing|"
21
+ r"justify|similarity|turnitin|plagiarism|caption|binding|pages?\b.*\b(excluding|minimum|maximum)|"
22
+ r"every chapter|page numbers?|references? (must|to be)|hard ?bound|spiral)\b", re.I)
23
+ NOTE = re.compile(r"<(?!<)[^<>\n]{2,80}>(?!>)")
24
+ PLACEHOLDER = re.compile(
25
+ r"<<[^<>]+>>|\[[A-Z][A-Za-z .]*(NAME|NUM|NO)[^\]]*\]|\.{5,}|_{5,}|\(GUIDE NAME\s*\)|\(Designation[^)]*\)|"
26
+ r"\(SPECIALIZATION NAME\)|STUDENT ?\d ?NAME|PROJECT TITLE|TITLE OF THE [A-Z]+|Name of the [Ss]tudent|Dr\. ?ABCD")
27
+
28
+
29
+ def ptext(el):
30
+ out = []
31
+ for x in el.iter():
32
+ if x.tag == qn("w:t") and x.text: out.append(x.text)
33
+ elif x.tag == qn("w:tab"): out.append("\t")
34
+ elif x.tag == qn("w:br"): out.append(" / ")
35
+ return "".join(out)
36
+
37
+
38
+ def describe(p):
39
+ size = bold = None
40
+ for r in p.runs:
41
+ if r.text.strip():
42
+ size = r.font.size.pt if r.font.size else None
43
+ bold = r.bold
44
+ break
45
+ if size is None and p.style is not None and p.style.font.size:
46
+ size = p.style.font.size.pt
47
+ if bold is None and p.style is not None:
48
+ bold = p.style.font.bold
49
+ al = p.alignment
50
+ return size, bold, (al.name if al is not None else "")
51
+
52
+
53
+ def main(path, out):
54
+ os.makedirs(out, exist_ok=True)
55
+ d = docx.Document(path)
56
+ body = d.element.body
57
+ lines, rules, notes, holders = [], [], Counter(), Counter()
58
+
59
+ def scan(t, where):
60
+ for m in NOTE.finditer(t): notes[m.group(0)] += 1
61
+ for m in PLACEHOLDER.finditer(t): holders[m.group(0).strip()] += 1
62
+ if RULE_WORDS.search(t) and len(t) > 25: rules.append(f"[{where}] {t.strip()}")
63
+
64
+ for i, el in enumerate(body.iterchildren()):
65
+ if el.tag == qn("w:sectPr"):
66
+ lines.append(f"{i:4d} FINAL-SECTPR")
67
+ continue
68
+ if el.tag == qn("w:tbl"):
69
+ t = Table(el, d)
70
+ n_rows, n_cols = len(t.rows), len(t.columns)
71
+ first = " | ".join(c.text.strip().replace("\n", " / ")[:70] for c in t.rows[0].cells)
72
+ lines.append(f"{i:4d} TABLE {n_rows}x{n_cols}: {first}")
73
+ for tr in el.iter(qn("w:p")):
74
+ scan(ptext(tr), f"{i} table")
75
+ continue
76
+ if el.tag != qn("w:p"):
77
+ lines.append(f"{i:4d} {el.tag.split('}')[1]}")
78
+ continue
79
+ p = Paragraph(el, d)
80
+ t = ptext(el)
81
+ size, bold, al = describe(p)
82
+ marks = []
83
+ if el.find(".//" + qn("w:sectPr")) is not None: marks.append("SECT")
84
+ if el.find(".//" + qn("w:pageBreakBefore")) is not None: marks.append("PBB")
85
+ if any(b.get(qn("w:type")) == "page" for b in el.iter(qn("w:br"))): marks.append("PB")
86
+ n_img = len(list(el.iter("{http://schemas.openxmlformats.org/drawingml/2006/main}blip")))
87
+ if n_img: marks.append(f"IMG{n_img}")
88
+ if el.find(".//" + qn("w:txbxContent")) is not None: marks.append("TEXTBOX")
89
+ if el.find(".//" + qn("w:numPr")) is not None: marks.append("LIST")
90
+ if el.find(".//" + qn("w:instrText")) is not None: marks.append("FIELD")
91
+ style = p.style.name if p.style is not None else "-"
92
+ if t.strip() or marks:
93
+ lines.append(f"{i:4d} P [{style}|{size or ''}|{'B' if bold else ''}|{al}]{' {' + ','.join(marks) + '}' if marks else ''} {t[:170]}")
94
+ scan(t, str(i))
95
+
96
+ secs = []
97
+ cm = lambda v: round(v / 360000, 2) if v is not None else None
98
+ for i, s in enumerate(d.sections):
99
+ pg = s._sectPr.find(qn("w:pgNumType"))
100
+ num = dict((k.split("}")[1], v) for k, v in pg.attrib.items()) if pg is not None else {}
101
+ foot = " ".join(p.text for p in s.footer.paragraphs).strip() if not s.footer.is_linked_to_previous else "(linked)"
102
+ fld = bool(s.footer._element.findall(".//" + qn("w:instrText")) or s.footer._element.findall(".//" + qn("w:fldSimple"))) if not s.footer.is_linked_to_previous else None
103
+ secs.append(f"{i}: page {cm(s.page_width)}x{cm(s.page_height)} cm margins L {cm(s.left_margin)} R {cm(s.right_margin)} "
104
+ f"T {cm(s.top_margin)} B {cm(s.bottom_margin)} cm numbering {num} footer {foot!r} pagefield={fld}")
105
+
106
+ with open(os.path.join(out, "structure.txt"), "w", encoding="utf-8") as f:
107
+ f.write("\n".join(lines))
108
+ with open(os.path.join(out, "sections.txt"), "w", encoding="utf-8") as f:
109
+ f.write("\n".join(secs))
110
+ with open(os.path.join(out, "rules.txt"), "w", encoding="utf-8") as f:
111
+ f.write("# RULE SENTENCES (template's own words)\n" + "\n".join(rules))
112
+ f.write("\n\n# INLINE NOTES (specimen hints: apply them to the text next to them, then delete them)\n")
113
+ f.write("\n".join(f"{v}x {k}" for k, v in notes.most_common()))
114
+ f.write("\n\n# PLACEHOLDERS TO FILL\n" + "\n".join(f"{v}x {k}" for k, v in holders.most_common()))
115
+
116
+ print(f"{os.path.basename(path)}: {len(lines)} body elements, {len(d.sections)} sections, {len(d.tables)} tables, "
117
+ f"{len(rules)} rule sentences, {sum(notes.values())} inline notes, {sum(holders.values())} placeholders")
118
+ print("wrote structure.txt, rules.txt, sections.txt to", out)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ if len(sys.argv) != 3:
123
+ sys.exit(__doc__)
124
+ main(sys.argv[1], sys.argv[2])
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env python3
2
+ """Render a report PDF so the pages can be looked at.
3
+ usage: python -X utf8 render_pages.py report.pdf outdir contact sheets of every page (sheet0.png, ...)
4
+ python -X utf8 render_pages.py report.pdf outdir 1,2,3 90 the listed physical pages at the given dpi (p1.png, ...)
5
+ """
6
+ import os, sys
7
+ import fitz
8
+ from PIL import Image
9
+
10
+ if len(sys.argv) < 3:
11
+ sys.exit(__doc__)
12
+ pdf, out = sys.argv[1], sys.argv[2]
13
+ os.makedirs(out, exist_ok=True)
14
+ d = fitz.open(pdf)
15
+ if len(sys.argv) > 3:
16
+ dpi = int(sys.argv[4]) if len(sys.argv) > 4 else 90
17
+ for n in (int(x) for x in sys.argv[3].split(",")):
18
+ d[n - 1].get_pixmap(dpi=dpi).save(os.path.join(out, f"p{n}.png"))
19
+ print("wrote", sys.argv[3], "to", out)
20
+ else:
21
+ ims = []
22
+ for p in d:
23
+ px = p.get_pixmap(dpi=45)
24
+ ims.append(Image.frombytes("RGB", (px.width, px.height), px.samples))
25
+ w, h = ims[0].size
26
+ cols = 10
27
+ for k in range(0, len(ims), 30):
28
+ sub = ims[k:k + 30]
29
+ rows = (len(sub) + cols - 1) // cols
30
+ c = Image.new("RGB", (w * cols, h * rows), "white")
31
+ for j, im in enumerate(sub):
32
+ c.paste(im, ((j % cols) * w, (j // cols) * h))
33
+ c.save(os.path.join(out, f"sheet{k // 30}.png"))
34
+ print(len(ims), "pages,", (len(ims) + 29) // 30, "sheets in", out)