docmd-cli 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (33) hide show
  1. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/PKG-INFO +36 -2
  2. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/README.md +35 -1
  3. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/__init__.py +19 -7
  4. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/cli.py +29 -2
  5. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/pyproject.toml +1 -1
  6. docmd_cli-0.1.1/tests/fixtures/generate_stress_fixtures.py +110 -0
  7. docmd_cli-0.1.1/tests/fixtures/merged_cells.pdf +74 -0
  8. docmd_cli-0.1.1/tests/fixtures/running_header.pdf +131 -0
  9. docmd_cli-0.1.1/tests/fixtures/stress.pdf +99 -0
  10. docmd_cli-0.1.1/tests/fixtures/with_image.pdf +85 -0
  11. docmd_cli-0.1.1/tests/test_postprocess_integration.py +68 -0
  12. docmd_cli-0.1.1/tests/test_postprocess_more_integration.py +91 -0
  13. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/.github/workflows/ci.yml +0 -0
  14. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/.github/workflows/release.yml +0 -0
  15. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/.gitignore +0 -0
  16. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/ARCHITECTURE.md +0 -0
  17. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/LICENSE +0 -0
  18. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/config.py +0 -0
  19. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/converters/__init__.py +0 -0
  20. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/converters/base.py +0 -0
  21. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/converters/marker_converter.py +0 -0
  22. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/converters/registry.py +0 -0
  23. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/errors.py +0 -0
  24. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/postprocess/__init__.py +0 -0
  25. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/postprocess/heading_normalize.py +0 -0
  26. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/postprocess/image_handling.py +0 -0
  27. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/docmd/postprocess/table_cleanup.py +0 -0
  28. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/tests/fixtures/generate_fixtures.py +0 -0
  29. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/tests/fixtures/sample.docx +0 -0
  30. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/tests/fixtures/sample.pdf +0 -0
  31. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/tests/test_cli.py +0 -0
  32. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/tests/test_converters.py +0 -0
  33. {docmd_cli-0.1.0 → docmd_cli-0.1.1}/tests/test_postprocess.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docmd-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Convert PDFs, DOCX, and PPTX to clean, structure-preserving Markdown.
5
5
  Project-URL: Homepage, https://github.com/taherzribi/docmd
6
6
  Project-URL: Issues, https://github.com/taherzribi/docmd/issues
@@ -107,12 +107,46 @@ native Pango/GObject/Cairo libraries, which `pip` cannot install for you:
107
107
  # macOS
108
108
  brew install pango
109
109
 
110
- # Debian/Ubuntu
110
+ # Debian/Ubuntu (24.04 and older)
111
111
  sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info
112
+
113
+ # Debian trixie (13) and newer: libgdk-pixbuf2.0-0 was renamed
114
+ sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info
112
115
  ```
113
116
 
114
117
  PDF conversion (the base install) does not need this.
115
118
 
119
+ ## Image handling
120
+
121
+ Images default to a text placeholder (`*[... omitted]*`) - no binary data, nothing to
122
+ resolve, safe for RAG chunking:
123
+
124
+ ```python
125
+ markdown = convert("report.pdf") # image_mode="placeholder" by default
126
+ ```
127
+
128
+ To keep real image links instead, use `image_mode="alt-text"` and pass `output_dir` so
129
+ the image files actually get saved somewhere the links can resolve to:
130
+
131
+ ```python
132
+ from docmd import convert_document
133
+ from docmd.config import ConvertConfig
134
+
135
+ result = convert_document(
136
+ "report.pdf",
137
+ config=ConvertConfig(image_mode="alt-text"),
138
+ output_dir="output/",
139
+ )
140
+ ```
141
+
142
+ From the CLI, `--image-dir` defaults to the output file's directory when `-o` is given:
143
+
144
+ ```bash
145
+ docmd convert report.pdf -o output/report.md --image-mode alt-text
146
+ ```
147
+
148
+ `image_mode="skip"` drops images entirely - no placeholder, no files.
149
+
116
150
  ## How it works
117
151
 
118
152
  `docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
@@ -80,12 +80,46 @@ native Pango/GObject/Cairo libraries, which `pip` cannot install for you:
80
80
  # macOS
81
81
  brew install pango
82
82
 
83
- # Debian/Ubuntu
83
+ # Debian/Ubuntu (24.04 and older)
84
84
  sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info
85
+
86
+ # Debian trixie (13) and newer: libgdk-pixbuf2.0-0 was renamed
87
+ sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info
85
88
  ```
86
89
 
87
90
  PDF conversion (the base install) does not need this.
88
91
 
92
+ ## Image handling
93
+
94
+ Images default to a text placeholder (`*[... omitted]*`) - no binary data, nothing to
95
+ resolve, safe for RAG chunking:
96
+
97
+ ```python
98
+ markdown = convert("report.pdf") # image_mode="placeholder" by default
99
+ ```
100
+
101
+ To keep real image links instead, use `image_mode="alt-text"` and pass `output_dir` so
102
+ the image files actually get saved somewhere the links can resolve to:
103
+
104
+ ```python
105
+ from docmd import convert_document
106
+ from docmd.config import ConvertConfig
107
+
108
+ result = convert_document(
109
+ "report.pdf",
110
+ config=ConvertConfig(image_mode="alt-text"),
111
+ output_dir="output/",
112
+ )
113
+ ```
114
+
115
+ From the CLI, `--image-dir` defaults to the output file's directory when `-o` is given:
116
+
117
+ ```bash
118
+ docmd convert report.pdf -o output/report.md --image-mode alt-text
119
+ ```
120
+
121
+ `image_mode="skip"` drops images entirely - no placeholder, no files.
122
+
89
123
  ## How it works
90
124
 
91
125
  `docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
@@ -18,7 +18,7 @@ from docmd.postprocess.image_handling import apply_image_handling
18
18
  from docmd.postprocess.table_cleanup import clean_tables
19
19
 
20
20
  __all__ = ["convert", "convert_document", "ConvertConfig", "ConversionResult"]
21
- __version__ = "0.1.0"
21
+ __version__ = "0.1.1"
22
22
 
23
23
 
24
24
  def convert_document(
@@ -26,6 +26,7 @@ def convert_document(
26
26
  *,
27
27
  filename: str | None = None,
28
28
  config: ConvertConfig | None = None,
29
+ output_dir: str | Path | None = None,
29
30
  ) -> ConversionResult:
30
31
  """Convert `source` and return the full result (markdown + page count +
31
32
  metadata), after docmd's post-processing pass has run.
@@ -33,6 +34,14 @@ def convert_document(
33
34
  `source` is either a path to a file, or raw bytes - in which case
34
35
  `filename` is required so docmd knows the format (its extension is used
35
36
  to pick a converter; the content itself is what gets converted).
37
+
38
+ `output_dir`: only meaningful with `config.image_mode == "alt-text"`,
39
+ which keeps real image links in the output Markdown - those links only
40
+ resolve if the image files actually exist somewhere, so pass the
41
+ directory to save them into (typically wherever the output .md file is
42
+ going). Without it, `alt-text` mode's links point to image filenames
43
+ that were never written anywhere. `placeholder` (the default) and
44
+ `skip` modes ignore this - they never reference image files at all.
36
45
  """
37
46
  config = config or ConvertConfig()
38
47
 
@@ -44,11 +53,11 @@ def convert_document(
44
53
  try:
45
54
  with os.fdopen(fd, "wb") as tmp:
46
55
  tmp.write(source)
47
- result = _convert_path(tmp_path, config)
56
+ result = _convert_path(tmp_path, config, output_dir)
48
57
  finally:
49
58
  os.unlink(tmp_path)
50
59
  else:
51
- result = _convert_path(str(source), config)
60
+ result = _convert_path(str(source), config, output_dir)
52
61
 
53
62
  return result
54
63
 
@@ -58,13 +67,16 @@ def convert(
58
67
  *,
59
68
  filename: str | None = None,
60
69
  config: ConvertConfig | None = None,
70
+ output_dir: str | Path | None = None,
61
71
  ) -> str:
62
72
  """Convert `source` (a file path, or bytes + filename) and return the
63
- resulting Markdown as a string."""
64
- return convert_document(source, filename=filename, config=config).markdown
73
+ resulting Markdown as a string. See `convert_document` re: `output_dir`."""
74
+ return convert_document(source, filename=filename, config=config, output_dir=output_dir).markdown
65
75
 
66
76
 
67
- def _convert_path(filepath: str, config: ConvertConfig) -> ConversionResult:
77
+ def _convert_path(
78
+ filepath: str, config: ConvertConfig, output_dir: str | Path | None
79
+ ) -> ConversionResult:
68
80
  converter = get_converter(filepath)
69
81
  result = converter.convert(filepath, config)
70
82
 
@@ -73,7 +85,7 @@ def _convert_path(filepath: str, config: ConvertConfig) -> ConversionResult:
73
85
  markdown = clean_tables(markdown)
74
86
  if config.normalize_headings:
75
87
  markdown = normalize_headings(markdown)
76
- markdown = apply_image_handling(markdown, result.images, config.image_mode)
88
+ markdown = apply_image_handling(markdown, result.images, config.image_mode, output_dir)
77
89
 
78
90
  return ConversionResult(
79
91
  markdown=markdown,
@@ -47,12 +47,39 @@ def main() -> None:
47
47
  show_default=True,
48
48
  help="How to represent images in the output.",
49
49
  )
50
- def convert(file: Path, output: Path | None, force_ocr: bool, use_llm: bool, image_mode: str) -> None:
50
+ @click.option(
51
+ "--image-dir",
52
+ "image_dir",
53
+ type=click.Path(file_okay=False, path_type=Path),
54
+ default=None,
55
+ help="Where to save image files in --image-mode alt-text. Defaults to "
56
+ "the output file's directory when -o is given; without -o, images "
57
+ "won't be saved unless this is set explicitly.",
58
+ )
59
+ def convert(
60
+ file: Path,
61
+ output: Path | None,
62
+ force_ocr: bool,
63
+ use_llm: bool,
64
+ image_mode: str,
65
+ image_dir: Path | None,
66
+ ) -> None:
51
67
  """Convert FILE to Markdown."""
52
68
  config = ConvertConfig(force_ocr=force_ocr, use_llm=use_llm, image_mode=image_mode)
53
69
 
70
+ if image_mode == "alt-text" and image_dir is None:
71
+ if output is not None:
72
+ image_dir = output.parent
73
+ else:
74
+ click.echo(
75
+ "warning: --image-mode alt-text with no -o/--image-dir - "
76
+ "image links in the output won't resolve to real files. "
77
+ "Pass --image-dir to save images somewhere.",
78
+ err=True,
79
+ )
80
+
54
81
  try:
55
- result = convert_document(str(file), config=config)
82
+ result = convert_document(str(file), config=config, output_dir=image_dir)
56
83
  except DocmdError as exc:
57
84
  click.echo(f"error: {exc}", err=True)
58
85
  sys.exit(1)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "docmd-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Convert PDFs, DOCX, and PPTX to clean, structure-preserving Markdown."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,110 @@
1
+ """Regenerates the stress-test PDF fixtures used to prove post-processing
2
+ fixes real Marker defects, not just hand-crafted markdown strings. See
3
+ tests/test_postprocess_integration.py for stress.pdf, and
4
+ tests/test_postprocess_more_integration.py for the ones generated here.
5
+
6
+ python tests/fixtures/generate_stress_fixtures.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from PIL import Image as PILImage
14
+ from reportlab.lib import colors
15
+ from reportlab.lib.pagesizes import LETTER
16
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
17
+ from reportlab.lib.units import inch
18
+ from reportlab.platypus import (
19
+ Image,
20
+ PageBreak,
21
+ Paragraph,
22
+ SimpleDocTemplate,
23
+ Spacer,
24
+ Table,
25
+ TableStyle,
26
+ )
27
+
28
+ FIXTURES_DIR = Path(__file__).parent
29
+ styles = getSampleStyleSheet()
30
+ body = styles["BodyText"]
31
+ h_title = ParagraphStyle("H0", fontSize=28, leading=32, fontName="Helvetica-Bold")
32
+ h_big = ParagraphStyle("H1", fontSize=22, leading=26, fontName="Helvetica-Bold")
33
+
34
+
35
+ def generate_running_header() -> None:
36
+ """4-page document with a bold header drawn at a fixed position on every
37
+ page (not a flowable heading) - tests whether that gets misdetected as a
38
+ duplicate heading, and whether structurally identical section headings
39
+ get assigned consistent levels throughout."""
40
+
41
+ def draw_header(canvas, _doc):
42
+ canvas.saveState()
43
+ canvas.setFont("Helvetica-Bold", 14)
44
+ canvas.drawString(72, 740, "CONFIDENTIAL - INTERNAL REPORT")
45
+ canvas.restoreState()
46
+
47
+ doc = SimpleDocTemplate(str(FIXTURES_DIR / "running_header.pdf"), pagesize=LETTER)
48
+ story = [Paragraph("Quarterly Risk Review", h_title), Spacer(1, 20)]
49
+ for section in range(1, 5):
50
+ story.append(Paragraph(f"Section {section}: Findings", h_big))
51
+ story.append(
52
+ Paragraph(
53
+ (f"This is body paragraph content for section {section}. ") * 20,
54
+ body,
55
+ )
56
+ )
57
+ story.append(PageBreak())
58
+ doc.build(story, onFirstPage=draw_header, onLaterPages=draw_header)
59
+
60
+
61
+ def generate_merged_cells() -> None:
62
+ """A table with a spanned header cell (merged columns) - tests whether
63
+ that breaks raw column-count reconstruction."""
64
+ rows = [
65
+ ["", "Q1 2026", "", "Q2 2026", ""],
66
+ ["Region", "Revenue", "Growth", "Revenue", "Growth"],
67
+ ["West", "$1.2M", "+5%", "$1.4M", "+17%"],
68
+ ["East", "$0.9M", "+2%", "$1.0M", "+11%"],
69
+ ]
70
+ doc = SimpleDocTemplate(str(FIXTURES_DIR / "merged_cells.pdf"), pagesize=LETTER)
71
+ table = Table(
72
+ rows,
73
+ style=TableStyle(
74
+ [
75
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.black),
76
+ ("SPAN", (1, 0), (2, 0)),
77
+ ("SPAN", (3, 0), (4, 0)),
78
+ ("BACKGROUND", (0, 0), (-1, 1), colors.lightgrey),
79
+ ]
80
+ ),
81
+ )
82
+ doc.build([Paragraph("Regional Revenue", h_big), Spacer(1, 10), table])
83
+
84
+
85
+ def generate_with_image() -> None:
86
+ """A PDF with a real embedded image, to test image_handling.py against
87
+ real Marker image extraction rather than a synthetic dict."""
88
+ img_path = FIXTURES_DIR / "_gen_chart.png"
89
+ PILImage.new("RGB", (300, 200), color=(70, 130, 180)).save(img_path)
90
+
91
+ doc = SimpleDocTemplate(str(FIXTURES_DIR / "with_image.pdf"), pagesize=LETTER)
92
+ doc.build(
93
+ [
94
+ Paragraph("Revenue Chart", h_big),
95
+ Spacer(1, 10),
96
+ Paragraph("See the chart below for regional performance.", body),
97
+ Spacer(1, 10),
98
+ Image(str(img_path), width=3 * inch, height=2 * inch),
99
+ Spacer(1, 10),
100
+ Paragraph("Chart data is preliminary and subject to revision.", body),
101
+ ]
102
+ )
103
+ img_path.unlink() # embedded in the PDF now, don't need the source file
104
+
105
+
106
+ if __name__ == "__main__":
107
+ generate_running_header()
108
+ generate_merged_cells()
109
+ generate_with_image()
110
+ print(f"wrote stress fixtures to {FIXTURES_DIR}")
@@ -0,0 +1,74 @@
1
+ %PDF-1.4
2
+ %���� ReportLab Generated PDF document (opensource)
3
+ 1 0 obj
4
+ <<
5
+ /F1 2 0 R /F2 3 0 R
6
+ >>
7
+ endobj
8
+ 2 0 obj
9
+ <<
10
+ /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
11
+ >>
12
+ endobj
13
+ 3 0 obj
14
+ <<
15
+ /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
16
+ >>
17
+ endobj
18
+ 4 0 obj
19
+ <<
20
+ /Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
21
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
22
+ >> /Rotate 0 /Trans <<
23
+
24
+ >>
25
+ /Type /Page
26
+ >>
27
+ endobj
28
+ 5 0 obj
29
+ <<
30
+ /PageMode /UseNone /Pages 7 0 R /Type /Catalog
31
+ >>
32
+ endobj
33
+ 6 0 obj
34
+ <<
35
+ /Author (\(anonymous\)) /CreationDate (D:20260918162747+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918162747+02'00') /Producer (ReportLab PDF Library - \(opensource\))
36
+ /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
37
+ >>
38
+ endobj
39
+ 7 0 obj
40
+ <<
41
+ /Count 1 /Kids [ 4 0 R ] /Type /Pages
42
+ >>
43
+ endobj
44
+ 8 0 obj
45
+ <<
46
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 500
47
+ >>
48
+ stream
49
+ GasalbAP0N&A70Vp-9o2Wp=eD-b`WU86("e7cKB,5`9lcPa:4qq@P(tY`O,]Za6u")iVSsT_nZ>!'F9Npl'_)WWf&+&uo"M<S;SNJQ\51bEBr"EJNqJ)3nW0U7)Oj#S9a"BWKOQf#t8QG.r]FrE!oel[,R2:^sN$K[+:a_DUPO=;K+'BWcc!<f03nK_'+#AkN:2FjDq_&fu'&q\g6&PA-RZmS&LGZ/\-&1=ej\YEP*'kqZfH'*Q.*mPsGb;(?2K8/2H2#)SB%GBB864%J<+%_J8lZD(cfpUfJE1HFGhRYKCsD@tF+GXn4Q5DPJg5jKR,D^co^2cOum(Tj4?-KdNKH<=DaUo3YuUNtRDBD"5?an\):.+7.VApU&?5,]WJjm4jMU%^&7a/@sV.6'4E@FO!8MV%@c@,@XU"QV-bjt@#hftnF^0\s/C;r#1\/&LrLYfuJQXh9^h\l#gnMn:+3@rCY5Ca+P;!Y*lN\<ILke\iKLrW,hE#lt~>endstream
50
+ endobj
51
+ xref
52
+ 0 9
53
+ 0000000000 65535 f
54
+ 0000000061 00000 n
55
+ 0000000102 00000 n
56
+ 0000000209 00000 n
57
+ 0000000321 00000 n
58
+ 0000000514 00000 n
59
+ 0000000582 00000 n
60
+ 0000000862 00000 n
61
+ 0000000921 00000 n
62
+ trailer
63
+ <<
64
+ /ID
65
+ [<04e44ee2661fab79030feb6409b489d5><04e44ee2661fab79030feb6409b489d5>]
66
+ % ReportLab generated PDF document -- digest (opensource)
67
+
68
+ /Info 6 0 R
69
+ /Root 5 0 R
70
+ /Size 9
71
+ >>
72
+ startxref
73
+ 1511
74
+ %%EOF
@@ -0,0 +1,131 @@
1
+ %PDF-1.4
2
+ %���� ReportLab Generated PDF document (opensource)
3
+ 1 0 obj
4
+ <<
5
+ /F1 2 0 R /F2 3 0 R
6
+ >>
7
+ endobj
8
+ 2 0 obj
9
+ <<
10
+ /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
11
+ >>
12
+ endobj
13
+ 3 0 obj
14
+ <<
15
+ /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
16
+ >>
17
+ endobj
18
+ 4 0 obj
19
+ <<
20
+ /Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 10 0 R /Resources <<
21
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
22
+ >> /Rotate 0 /Trans <<
23
+
24
+ >>
25
+ /Type /Page
26
+ >>
27
+ endobj
28
+ 5 0 obj
29
+ <<
30
+ /Contents 12 0 R /MediaBox [ 0 0 612 792 ] /Parent 10 0 R /Resources <<
31
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
32
+ >> /Rotate 0 /Trans <<
33
+
34
+ >>
35
+ /Type /Page
36
+ >>
37
+ endobj
38
+ 6 0 obj
39
+ <<
40
+ /Contents 13 0 R /MediaBox [ 0 0 612 792 ] /Parent 10 0 R /Resources <<
41
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
42
+ >> /Rotate 0 /Trans <<
43
+
44
+ >>
45
+ /Type /Page
46
+ >>
47
+ endobj
48
+ 7 0 obj
49
+ <<
50
+ /Contents 14 0 R /MediaBox [ 0 0 612 792 ] /Parent 10 0 R /Resources <<
51
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
52
+ >> /Rotate 0 /Trans <<
53
+
54
+ >>
55
+ /Type /Page
56
+ >>
57
+ endobj
58
+ 8 0 obj
59
+ <<
60
+ /PageMode /UseNone /Pages 10 0 R /Type /Catalog
61
+ >>
62
+ endobj
63
+ 9 0 obj
64
+ <<
65
+ /Author (\(anonymous\)) /CreationDate (D:20260918162747+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918162747+02'00') /Producer (ReportLab PDF Library - \(opensource\))
66
+ /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
67
+ >>
68
+ endobj
69
+ 10 0 obj
70
+ <<
71
+ /Count 4 /Kids [ 4 0 R 5 0 R 6 0 R 7 0 R ] /Type /Pages
72
+ >>
73
+ endobj
74
+ 11 0 obj
75
+ <<
76
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 361
77
+ >>
78
+ stream
79
+ Gb"/chbJeX'ZTTeMXM0P7+reOn,*7oahf$h(I5uBe\Nf8g"T^lL:@V#9Xn<\1sL5>cgPXF;?g>7VUonlg^u.@#0&qZ0ZS3!1\bLn)`kj$VB`8*[1T7A[`F\ClI_a=bklZ"KF`N"iU%$i#UAp#88Vl:E?<RHkQQ(b*+,Ymh0BO6a-@13fijSb^V1/T.Gi_8?=RK^)\_An,E[*qhfafEonka+'\V(k`\GH_P[ejqrf+$%LV'#RPbkc9$a:QQU0*9sYD1,"I.2U:hB&CP@-`$n?]`j5Jst$IEeeKUP?]Hh2:Ae/Z_GI>Pc,'U/Gqa0i7o<7nWVeV7V).t45PdP??V)cFmmhn5t:`K_"\,!^:87~>endstream
80
+ endobj
81
+ 12 0 obj
82
+ <<
83
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 319
84
+ >>
85
+ stream
86
+ Gb"/c]5E6T(km1*if#]lM/Bf]AEsCEUklDMIfVkK6D^%P=Sh_K/toX>L.Z9E/^*uDk<$*uF"fD[?1T<17$pIa>_)P+dZ^==6?:A3bm4uh);eA@`l*-U&dSW'JW=]Af1?[kKF,VY/)2@E-qk;\9uGS*8p?p=d&_#41X\Y*K!1F/@:II#l=-j@+?T,=L9Ll9hjJH@GBmCn)IeQ%eD!k;bqHpU`^:lP0c'$n@W$bMZO.)66eT9!9ssr<.BBeLc;"W*Q#:fW`P/A">^UekJ+^dk4!EX^HD"!R(fq6\#oTN'!I&n(gZr-VoH+7,0.TP^h#~>endstream
87
+ endobj
88
+ 13 0 obj
89
+ <<
90
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 320
91
+ >>
92
+ stream
93
+ Gb"/c:JZTs(rl#lMW]dB@Tt?^Dn85$Z7okY(I5D`9NEKZn_77t6el+*-/=eE:%S@0IS\^o*6\h/f6e+A'(-tS/jNW[kt05'+ZWnibm4uh);eA@`l*-U&dSW'JbF%qb9Vu.@7'M-!dBG6$Icq0e*q4BdLGCRFPF4<)sO&'_,neS0JR0LFYVH[OI^W/6WeuXmnC6l:#;2$`ksU,;Qi`!2#dh/N5n2T@m$_tLq*\$cRLK!G%Cb35H=B8%U#>;F`uC\YB7D(/[eZ=k30_#jjclamY$P6l@Ci:@pD1d-=i(8#Mf"FY-3&8d=2jLIK3B<S+6~>endstream
94
+ endobj
95
+ 14 0 obj
96
+ <<
97
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 319
98
+ >>
99
+ stream
100
+ Gb"/c]5E6T(km1*if#]lM/Bf]AEsCEUklDMIfVkK6D^%P=Sh_K/toX>L.Z9E/^*uDk<$*uF"fD[?1T<17$pIa>_)P+dZ^==6?:A3bm4uh);eA@`l*-U&dSW'JW=]Af1?[kKF,VY/)2@E-qk;\9uGS*8p?p=d&_#41X\Y*K!1F/@:II#l=-j@+?T,=L9Ll9hjJH@GBmCn)KL\5eD!k;bqHpU`^:lP0c'$n@W$bMZO.)66eT9!9ssr<.BBeLc;"W*Q#:fW`P/A">^UekJ+^dk4!EX^HD"!R(fq6\#oTN'!I&n(gZr-VoH+7,07WM^#6~>endstream
101
+ endobj
102
+ xref
103
+ 0 15
104
+ 0000000000 65535 f
105
+ 0000000061 00000 n
106
+ 0000000102 00000 n
107
+ 0000000209 00000 n
108
+ 0000000321 00000 n
109
+ 0000000516 00000 n
110
+ 0000000711 00000 n
111
+ 0000000906 00000 n
112
+ 0000001101 00000 n
113
+ 0000001170 00000 n
114
+ 0000001450 00000 n
115
+ 0000001528 00000 n
116
+ 0000001980 00000 n
117
+ 0000002390 00000 n
118
+ 0000002801 00000 n
119
+ trailer
120
+ <<
121
+ /ID
122
+ [<71bc982e6efde47f41d5e095c3f3e0e4><71bc982e6efde47f41d5e095c3f3e0e4>]
123
+ % ReportLab generated PDF document -- digest (opensource)
124
+
125
+ /Info 9 0 R
126
+ /Root 8 0 R
127
+ /Size 15
128
+ >>
129
+ startxref
130
+ 3211
131
+ %%EOF
@@ -0,0 +1,99 @@
1
+ %PDF-1.4
2
+ %���� ReportLab Generated PDF document (opensource)
3
+ 1 0 obj
4
+ <<
5
+ /F1 2 0 R /F2 3 0 R /F3 4 0 R
6
+ >>
7
+ endobj
8
+ 2 0 obj
9
+ <<
10
+ /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
11
+ >>
12
+ endobj
13
+ 3 0 obj
14
+ <<
15
+ /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
16
+ >>
17
+ endobj
18
+ 4 0 obj
19
+ <<
20
+ /BaseFont /Helvetica-BoldOblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
21
+ >>
22
+ endobj
23
+ 5 0 obj
24
+ <<
25
+ /Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
26
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
27
+ >> /Rotate 0 /Trans <<
28
+
29
+ >>
30
+ /Type /Page
31
+ >>
32
+ endobj
33
+ 6 0 obj
34
+ <<
35
+ /Contents 11 0 R /MediaBox [ 0 0 612 792 ] /Parent 9 0 R /Resources <<
36
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
37
+ >> /Rotate 0 /Trans <<
38
+
39
+ >>
40
+ /Type /Page
41
+ >>
42
+ endobj
43
+ 7 0 obj
44
+ <<
45
+ /PageMode /UseNone /Pages 9 0 R /Type /Catalog
46
+ >>
47
+ endobj
48
+ 8 0 obj
49
+ <<
50
+ /Author (\(anonymous\)) /CreationDate (D:20260918161648+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918161648+02'00') /Producer (ReportLab PDF Library - \(opensource\))
51
+ /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
52
+ >>
53
+ endobj
54
+ 9 0 obj
55
+ <<
56
+ /Count 2 /Kids [ 5 0 R 6 0 R ] /Type /Pages
57
+ >>
58
+ endobj
59
+ 10 0 obj
60
+ <<
61
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 1689
62
+ >>
63
+ stream
64
+ Gat%d>AkH>&;B$?/,6=ZDEt88?QOPc$5>dYfE1p9`bWVGCq6k<+TK1:mL7&l`"<A%M7E/F_m<"-*3E`qrQ+6l0+X$?\n40VOA%.4^1G`o$sS3ln(24PSk1&Q?nSG^Ve]bQ[_"Uo^F%,tl$"2s/;hQ2B2L/)+a%&/OK.UcG5f\Fj3.+M?!VBgB6H:!D0PlLf_QOk^LJ';Ql:HL5Ki3GjQ:BagAep`o1uRMme)&,NkLL^dtX;nbnI!:-Dij_:]00_ZXGe&P.93%Z.nZ+mD"\JpAXk\](BLFhJr88&%3GGm^J6-ZFa")`7jE9etgJfSC[=Z?I$UY?*g(1FsT!R0_[%FDldJfs%V;j'5rS5b/dS/)AhY8mZP5,MtO`r.B-[^9#jViH(F,bdp8nQ<DceuPci**j&f"Pe/'YD4jHX"<d!HSnaU0QN[eI<:)3J;J%^>W5)uKX]q21sf?\eA#]N?@PGAlmZ]khH6j!WV!"Y3-j(UN(rjLKDO#Kcnf$:#!0DhrK*WWMf7Hsmboe^('"7RErGL=chJ3>&Hqhb/"q$\h\^ar$?JcJ2)%2]3K4=J7(Bfo$&"RN%aZ#M,=Hns!Aa2e.][F[\UTNP+O:4aSg;,@?!5l_GLE#f!3#T$,u+YfUq^W.!88/*R^QII/?6;I[f4TH!^EN^<0l2d4%UI1Xu+IDa\8e6NPVZG+i&0@q6+uBcKkWtNgE8#FQ!eOQM_hgX=>8fg1"uuYo2KGQ:/][N$6Lr5^6U3=5C@$!QA<qtsJ3aa^KCdmq`hPpf^n)+5\h*s1/?*VB6'>DT63@E`!"[q+W>]r9P(4jfSEr$7jJMrT"Xuqo*2*Og*.WfP\FC"BjiAK5:pJ=YOIHB8+TY.K7"tht#K;=k6NWu0o_;0W5]@?(V?A/A&=F.B*i.lFd4#*!C;VLTBaG&?HH;1u,jHO?!lBSKgpWe#lENLDTQIXj+?F3\ErqN'M9H83&'FKOl'tRN95@FR[Xd-YL^XV]U*)3>+?X>X<@_"@+CN;c-TXsf<2MqC"=-hSR1g,QN8`@93<<:Zq5!J@IBJ+YUkjs:8>7:`.Ngk+jE$HD$HSf8j>q\i_+>6,'p(gPA6(s?E0;_=B*DhF/LO9%.j-l%,,54&W/-B>#S/OJ?)45^=Y9T2FIt'+2go$Z_ZGuDW!.erJ-YOg1iuhbp4ORAk6o@*K#k0H,RPa5!l@;q2AbSaJ1S+k@](Znl?*/r']QtB>3mdEfYMO)l'N$/r4mO1JNpY1.h9m/r5&kGm&\097Sl2O3[=EkLg[j.^`KRBOTE9NS=mjHE\,34agIl'JN`ss_0!CXW'#TZKs7oaq@*KcJ;W.X'/'p.^+FHhK'N-[Z!_kRku7Y<?O,FM=s^QKCpLdm?_)U#%kNc*R0N0A40BTp"ce$raKNV`9RjdmHgT!@c;p6Z0<`,4n-+%XCtR9;&F$L!XoK["F<U.J$1;am%naZlWfRF]@&o.TTG4c5$>E<DZ<D`!?pVi5Goi3%YF=W?.CcC'*gD8URccscW2b+aP_,j'MaMP#WoeS'$GL8H#Gkh/k'9c1\n[fAV')i$H%6&9X6+\($DD4+#Gp@Yk'9c1VJ;\-V4ajNGu+jBDCuFDA(<X^>5P>+5;]hAG-,Y,ID;h[F\cgf9Ko>alhp"OO$gtn1KW^7LPdo@S\n^#5?3sco"gefo1,m1Spo>FImINnLB~>endstream
65
+ endobj
66
+ 11 0 obj
67
+ <<
68
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 1195
69
+ >>
70
+ stream
71
+ Gasb[>E9FX&4YRK/,[a)J=<RCD>SI"fp,SD"@V6p"?cj#/hiJlr]\%qg$U#d=<j3ClQhaZEITDF`l!j.s*qUgm*>e2iuc*jA;<f)oNnlAC\I%tqlpf"$>9'!0kFp@6FaaRdV:#Ze)0n$]A)fXV'V=/m&akQKk'j-]6pmDl*USDm<E@nTinm_h%ll`B87H"m/;8'p$VN64+(On(JcHspJNm0pX>K?5.4jKY8>fg3FV!_D>aE[qm`4e-.i#_(A>Z9iqW:f)@`ku7&&Pnh&jCn-3+5`-64N;A<_?gGRk!'J1%+).i-hEfY=TX*nJY#,O&4*f%]55n)MVeLaFKF3?a5S*B6,(4=t=1M^"_7];;("8^tfRiCF:V(PNPNKn'<"*Xk@8:KG\CFANYQp?hU^F(^0X1H$tk0$`Y\QZNA0"!m/R@*eq!Z@97%dQ518q>2SMfBs^E]*Q5`,Y96B2b"f)Mu]H>Rg%EN&[5o,jBsS_R#F(Q*K9&+(O@bP!B_rT88_T::iaVHnPJ.$'NF7j"<kJcKcUXh!)FdASaU)5)Y&qd#iBqqTn$A\16`?5.+EgS&AjW1Zd9^,KTJ>A[#Ds*E_35$PlX4p2AS>-#Q[&eOE/'V^S)4Ce=I>"0GG'l5+OY7+2&,;%LbN$:Wh!YFhY--7ur=T:kPHl?nrp]"XMKO*#9*WFGMqI%h$IM0bVC7J7rsILBSsNM&[9BT$/sZ?7)B[YgfiIA"hgr->7Ern=)u4Z.T<5C!j8.oU+Q,!l9oYJZWpbaXP&Dd\+Wm(Q1)$R\cfm"?S@_R)(I12%)6$+IGk;o[k^)E'+]<Z1N8@.(.%d?jc*9!h,VfoHjZdCfUR[Edm_F*Xq\fEFF5KW;"'(kCUtGhd^YH".`RIB82##R:,CDDsi$o*n]?Gj>6j\^0'Mdd'LO0j$>fNrtkZ_K6-hbIs67WI[9$j%3F0^igP;:"fXk?Dm&VX,ujFMHs/jdHO[L#Rp((aD_L]09n/qGcH#lcKZBpIF8:b:C[ej`KO3*;51\Y3Xa-6Dnu2nQet4V?>6tZg8EX_2/kWr<:2s,<VYQ22U(gS%)],=nRc\U@L%%QI+YHC'29k0U'UGVA>a0hXR`@iGaJO5fPQGcmo@htY]mXa7S'JH#2GIOWgT'u=DOS!sh.B%D\fJpoa]j;Ibtom8"7Wl:C*ioQH*\*Up+H<Jh(LG8*K^~>endstream
72
+ endobj
73
+ xref
74
+ 0 12
75
+ 0000000000 65535 f
76
+ 0000000061 00000 n
77
+ 0000000112 00000 n
78
+ 0000000219 00000 n
79
+ 0000000331 00000 n
80
+ 0000000450 00000 n
81
+ 0000000644 00000 n
82
+ 0000000838 00000 n
83
+ 0000000906 00000 n
84
+ 0000001186 00000 n
85
+ 0000001251 00000 n
86
+ 0000003032 00000 n
87
+ trailer
88
+ <<
89
+ /ID
90
+ [<363e53a615617ff0e3d5d99a726c39b8><363e53a615617ff0e3d5d99a726c39b8>]
91
+ % ReportLab generated PDF document -- digest (opensource)
92
+
93
+ /Info 8 0 R
94
+ /Root 7 0 R
95
+ /Size 12
96
+ >>
97
+ startxref
98
+ 4319
99
+ %%EOF
@@ -0,0 +1,85 @@
1
+ %PDF-1.4
2
+ %���� ReportLab Generated PDF document (opensource)
3
+ 1 0 obj
4
+ <<
5
+ /F1 2 0 R /F2 3 0 R
6
+ >>
7
+ endobj
8
+ 2 0 obj
9
+ <<
10
+ /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
11
+ >>
12
+ endobj
13
+ 3 0 obj
14
+ <<
15
+ /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
16
+ >>
17
+ endobj
18
+ 4 0 obj
19
+ <<
20
+ /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 200 /Length 255 /Subtype /Image
21
+ /Type /XObject /Width 300
22
+ >>
23
+ stream
24
+ Gb"0<!=8`+$j3/G$ar_:p`'OXFAZ%<<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3&lcN@.'qUP~>endstream
25
+ endobj
26
+ 5 0 obj
27
+ <<
28
+ /Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
29
+ /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
30
+ /FormXob.33833d909d83ea6c265a8c21bc2adfc1 4 0 R
31
+ >>
32
+ >> /Rotate 0 /Trans <<
33
+
34
+ >>
35
+ /Type /Page
36
+ >>
37
+ endobj
38
+ 6 0 obj
39
+ <<
40
+ /PageMode /UseNone /Pages 8 0 R /Type /Catalog
41
+ >>
42
+ endobj
43
+ 7 0 obj
44
+ <<
45
+ /Author (\(anonymous\)) /CreationDate (D:20260918162747+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260918162747+02'00') /Producer (ReportLab PDF Library - \(opensource\))
46
+ /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
47
+ >>
48
+ endobj
49
+ 8 0 obj
50
+ <<
51
+ /Count 1 /Kids [ 5 0 R ] /Type /Pages
52
+ >>
53
+ endobj
54
+ 9 0 obj
55
+ <<
56
+ /Filter [ /ASCII85Decode /FlateDecode ] /Length 341
57
+ >>
58
+ stream
59
+ Gas2Dc#/.f&;9L7`>mVRLi'qpCYIq#93Vn>)RC\Wr1o(ClJ'pXW)@>_<jLf6mPgp"?uEK(9abcYCj$e'+KrT@0Ll%nk</MJpiSAmO@m&pV_PC.IGiEG0?N%WNZoT.7EC=*pI5tac@j5j[c9OA,P$VHr9EDt4n\HD.bU8gQp"_qk*$2Aq+cV55VSBL$>TDj<5"!^IGK+Y_258j\%4@0Y%C;Y1)%tJ&gld<K$.;Wj(jprB'6rOI1\r2MP;k9=fEUndKl7SAHXZFct-cJT)/JAE@,Ug&>uSJh_)_]N`(oVomi7&VJb]pgMc;Y`,I>hA4DNkCZSG)Y*W]5#^uc+qh+V~>endstream
60
+ endobj
61
+ xref
62
+ 0 10
63
+ 0000000000 65535 f
64
+ 0000000061 00000 n
65
+ 0000000102 00000 n
66
+ 0000000209 00000 n
67
+ 0000000321 00000 n
68
+ 0000000766 00000 n
69
+ 0000001022 00000 n
70
+ 0000001090 00000 n
71
+ 0000001370 00000 n
72
+ 0000001429 00000 n
73
+ trailer
74
+ <<
75
+ /ID
76
+ [<a37d360f94bbcb79be8c654f5f762036><a37d360f94bbcb79be8c654f5f762036>]
77
+ % ReportLab generated PDF document -- digest (opensource)
78
+
79
+ /Info 7 0 R
80
+ /Root 6 0 R
81
+ /Size 10
82
+ >>
83
+ startxref
84
+ 1860
85
+ %%EOF
@@ -0,0 +1,68 @@
1
+ """Integration test proving post-processing fixes a *real* Marker bug, not
2
+ just hand-crafted markdown strings (see tests/test_postprocess.py for those -
3
+ useful for fast unit coverage, but circular as evidence of real-world value,
4
+ since the input was written to match what the regex expects).
5
+
6
+ fixtures/stress.pdf has a 45-row table that spans a PDF page break
7
+ (reportlab's repeatRows=1, matching how real multi-page business reports are
8
+ laid out). Marker processes pages independently, so it renders this as two
9
+ separate Markdown tables with a duplicated header row in between - this test
10
+ converts the real PDF through real Marker, confirms that split actually
11
+ happens (so this test fails loudly if a future Marker version stops doing
12
+ it, rather than silently testing nothing), then confirms docmd's full
13
+ convert() pipeline merges them back into one continuous table.
14
+ """
15
+
16
+ from pathlib import Path
17
+
18
+ from docmd import convert_document
19
+ from docmd.config import ConvertConfig
20
+ from docmd.converters.marker_converter import MarkerConverter
21
+
22
+ FIXTURE = Path(__file__).parent / "fixtures" / "stress.pdf"
23
+
24
+
25
+ def test_marker_really_does_split_a_page_spanning_table():
26
+ """Documents the real upstream behavior this feature exists to fix."""
27
+ raw = MarkerConverter().convert(str(FIXTURE), ConvertConfig())
28
+ # Raw Marker output pads cell widths with extra spaces for column
29
+ # alignment (e.g. "| Region | Rep |"), so match loosely rather than
30
+ # on an exact literal string.
31
+ header_count = sum(
32
+ 1
33
+ for line in raw.markdown.splitlines()
34
+ if "Region" in line and "Revenue" in line and line.strip().startswith("|")
35
+ )
36
+ assert header_count >= 2, (
37
+ "expected Marker to render the page-spanning table as 2+ blocks "
38
+ "with a repeated header - if this fails, Marker's behavior changed "
39
+ "and test_docmd_merges_the_split_table's premise needs revisiting"
40
+ )
41
+
42
+
43
+ def test_docmd_merges_the_split_table():
44
+ result = convert_document(str(FIXTURE))
45
+ markdown = result.markdown
46
+
47
+ # One continuous table: header appears exactly once, not once per page.
48
+ assert markdown.count("| Region | Rep | Deal | Revenue | Status |") == 1
49
+
50
+ # All 45 data rows survived the merge - none silently dropped, including
51
+ # the ones on both sides of where the original page-break duplicate
52
+ # header used to sit (rows 27 and 28 of 45).
53
+ for i in range(1, 46):
54
+ assert f"Deal-{1000 + i}" in markdown
55
+
56
+ # The row right after the original page-break duplicate header is still
57
+ # present as a *data* row, not swallowed along with the header.
58
+ assert "North | Rep 28 | Deal-1028" in markdown
59
+
60
+
61
+ def test_docmd_promotes_first_heading_to_h1():
62
+ """Source PDF's title paragraph rendered as H2 in raw Marker output (its
63
+ font size wasn't visually distinct enough for Marker's heading-level
64
+ detection) - docmd promotes a document's opening heading to H1 rather
65
+ than leaving it start at H2 with no root."""
66
+ result = convert_document(str(FIXTURE))
67
+ first_line = result.markdown.splitlines()[0]
68
+ assert first_line.startswith("# ") and "Annual Sales Report" in first_line
@@ -0,0 +1,91 @@
1
+ """More real-Marker-bug regression tests, generated by "do many more tests" -
2
+ each one converts a real PDF through real Marker and checks docmd's output,
3
+ not hand-written markdown. See test_postprocess_integration.py for the
4
+ first one (page-spanning table). Honest negative results are included too,
5
+ not just wins - see test_merged_header_cells_survive_cleanly.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from docmd import convert_document
11
+ from docmd.config import ConvertConfig
12
+ from docmd.converters.marker_converter import MarkerConverter
13
+
14
+ FIXTURES = Path(__file__).parent / "fixtures"
15
+
16
+
17
+ def test_inconsistent_heading_levels_get_normalized_to_match():
18
+ """running_header.pdf has 4 structurally identical section headings
19
+ (same paragraph style). Raw Marker assigns them *different* levels -
20
+ Section 1 comes out H3, Sections 2-4 come out H2, a genuine model
21
+ inconsistency, not a deliberately constructed skip. Confirm that first,
22
+ then confirm docmd normalizes them to be consistent."""
23
+ raw = MarkerConverter().convert(str(FIXTURES / "running_header.pdf"), ConvertConfig())
24
+ assert "### **Section 1: Findings**" in raw.markdown
25
+ assert "## **Section 2: Findings**" in raw.markdown
26
+
27
+ result = convert_document(str(FIXTURES / "running_header.pdf"))
28
+ for n in range(1, 5):
29
+ assert f"## **Section {n}: Findings**" in result.markdown
30
+ assert f"### **Section {n}: Findings**" not in result.markdown
31
+
32
+
33
+ def test_running_header_does_not_leak_into_output():
34
+ """The bold text drawn at a fixed position on every page ("CONFIDENTIAL
35
+ - INTERNAL REPORT") never appears in Marker's raw markdown at all -
36
+ Marker's own PageHeaderProcessor already strips repeated page headers.
37
+ This is an honest negative result: docmd's duplicate-heading dedup
38
+ wasn't needed here because Marker solved it upstream. Pinned as a
39
+ regression test so a future Marker version silently changing this
40
+ doesn't go unnoticed."""
41
+ raw = MarkerConverter().convert(str(FIXTURES / "running_header.pdf"), ConvertConfig())
42
+ assert "CONFIDENTIAL" not in raw.markdown
43
+
44
+
45
+ def test_merged_header_cells_survive_cleanly():
46
+ """merged_cells.pdf has a spanned header cell (colspan). Honest negative
47
+ result: Marker already renders this as a clean, consistent 5-column
48
+ table (blank cells for the spanned columns) with no ragged rows -
49
+ nothing for table_cleanup.py to fix here. Pinned so this doesn't
50
+ silently regress, and so nobody assumes this case needs a fix it
51
+ doesn't need."""
52
+ raw = MarkerConverter().convert(str(FIXTURES / "merged_cells.pdf"), ConvertConfig())
53
+ table_lines = [l for l in raw.markdown.splitlines() if l.strip().startswith("|")]
54
+ # spanned quarter-label row, column-label row, separator, 2 data rows.
55
+ assert len(table_lines) == 5
56
+ assert all(line.count("|") == 6 for line in table_lines) # 5 columns, consistent
57
+
58
+ result = convert_document(str(FIXTURES / "merged_cells.pdf"))
59
+ assert "West" in result.markdown and "$1.2M" in result.markdown
60
+
61
+
62
+ def test_alt_text_mode_actually_saves_the_image(tmp_path):
63
+ """Regression test for a real bug found while doing this testing:
64
+ `output_dir` was accepted by apply_image_handling() but never actually
65
+ threaded through from convert()/convert_document(), so alt-text mode
66
+ produced a markdown image link pointing to a file that was never
67
+ written anywhere. Fixed in docmd/__init__.py and docmd/cli.py."""
68
+ result = convert_document(
69
+ str(FIXTURES / "with_image.pdf"),
70
+ config=ConvertConfig(image_mode="alt-text"),
71
+ output_dir=tmp_path,
72
+ )
73
+ assert "![Image 1](" in result.markdown
74
+
75
+ saved_images = list(tmp_path.glob("*.jpeg"))
76
+ assert len(saved_images) == 1
77
+ assert saved_images[0].name in result.markdown
78
+ # A real, valid JPEG - not an empty/placeholder file.
79
+ assert saved_images[0].read_bytes()[:2] == b"\xff\xd8"
80
+
81
+
82
+ def test_placeholder_mode_never_writes_image_files(tmp_path):
83
+ """The default mode should never touch the filesystem, even if an
84
+ output_dir happens to be passed."""
85
+ result = convert_document(
86
+ str(FIXTURES / "with_image.pdf"),
87
+ config=ConvertConfig(image_mode="placeholder"),
88
+ output_dir=tmp_path,
89
+ )
90
+ assert "omitted" in result.markdown
91
+ assert list(tmp_path.iterdir()) == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes