docconvert-local 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docconvert/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """DocConvert - 文档转换工具
2
+
3
+ The GUI is imported lazily so CLI / library use (``python main.py convert``
4
+ or ``from docconvert.controller import ConversionController``) does not
5
+ require the Tkinter package.
6
+ """
7
+
8
+ __all__ = ["DocConvertApp"]
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name == "DocConvertApp":
13
+ from docconvert.gui.app import DocConvertApp
14
+ return DocConvertApp
15
+ raise AttributeError(f"module 'docconvert' has no attribute {name!r}")
@@ -0,0 +1,5 @@
1
+ from docconvert.chunkers.table_chunker import BaseChunker
2
+
3
+ __all__ = [
4
+ "BaseChunker",
5
+ ]
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class BaseChunker(ABC):
8
+ @abstractmethod
9
+ def chunk(self, content: Any, **kwargs) -> list[Any]:
10
+ ...
@@ -0,0 +1,7 @@
1
+ from docconvert.cleaners.base import BaseCleaner
2
+ from docconvert.cleaners.word_md import WordMdCleaner
3
+
4
+ __all__ = [
5
+ "BaseCleaner",
6
+ "WordMdCleaner",
7
+ ]
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class BaseCleaner(ABC):
7
+
8
+ @abstractmethod
9
+ def clean(self, content: str, **kwargs) -> str:
10
+ ...
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ from docconvert.cleaners.base import BaseCleaner
7
+ from docconvert.config import AppConfig, DEFAULT_CONFIG
8
+
9
+
10
+ _UNESCAPE_PATTERN = re.compile(r'\\([\\\[\]\-.\(\)])')
11
+ _HORIZ_WS_PATTERN = re.compile(r'[ \t]+')
12
+ _FULLWIDTH_SPACE = '\u3000'
13
+ # A line that opens or closes a fenced code block (``` or ~~~), possibly
14
+ # with a language tag: "```python". Used to keep code-block whitespace intact.
15
+ _FENCE_PATTERN = re.compile(r'^\s*(?:`{3,}|~{3,})')
16
+
17
+
18
+ class WordMdCleaner(BaseCleaner):
19
+ """
20
+ Defense-in-depth post-processor for Word -> Markdown output.
21
+
22
+ WordConverter already strips header/footer references in the DOCX XML
23
+ before conversion. This cleaner is the regex safety net for whatever
24
+ page-number artifacts leak through mammoth, plus general whitespace
25
+ cleanup that mammoth cannot infer from Word's XML.
26
+
27
+ Page-number rules (line-level, anchored to ``strip()`` so they only
28
+ match standalone markers, never page numbers embedded in body text):
29
+
30
+ - Inline refs: [1], [12]
31
+ - Chinese: 第3页, 第 5 页
32
+ - Bordered: - 1 -, -12-
33
+ - Western: Page 1, page 5, Pág. 3, P. 7
34
+ - Multi-form: Page 1 of 10, Page 3 / 10
35
+
36
+ Document-level rules applied after the line-level pass:
37
+
38
+ - ``remove_duplicate_headers`` — drop consecutive duplicate lines
39
+ (the typical leak when mammoth flattens page headers / footers
40
+ into the body of every page). Only consecutive duplicates are
41
+ removed; non-adjacent repeats are kept.
42
+ - ``remove_empty_lines`` — collapse runs of ≥2 empty lines down to
43
+ a single empty line. Whitespace-only lines count as empty.
44
+ - ``normalize_spaces`` — convert U+3000 (full-width space) and tabs
45
+ to a single space, collapse internal runs of horizontal whitespace,
46
+ and strip trailing whitespace. Leading whitespace is preserved so
47
+ indented markdown (code blocks, list items, tables) is not
48
+ destroyed.
49
+
50
+ All four rules skip lines inside fenced (````` ``` ```` / ``~~~``) or
51
+ indented (≥4 leading spaces) code blocks: page markers, blank lines,
52
+ repeated lines and internal spacing are all significant inside code,
53
+ and the cleaner must not corrupt it.
54
+
55
+ Rule activation is driven by ``AppConfig.cleaning_rules``. The four
56
+ documented keys all have working implementations now:
57
+
58
+ - ``remove_page_numbers`` (default True) → enables all 5 page rules
59
+ - ``remove_duplicate_headers`` (default True)
60
+ - ``remove_empty_lines`` (default True)
61
+ - ``normalize_spaces`` (default True)
62
+
63
+ When constructed without a config, ``DEFAULT_CONFIG`` is used and
64
+ every rule is active (backward-compatible default).
65
+ """
66
+
67
+ _RULES = (
68
+ re.compile(r'^\[\d+\]$'),
69
+ re.compile(r'^第\s*\d+\s*页$'),
70
+ re.compile(r'^-\s*\d+\s*-$'),
71
+ re.compile(r'^page\s*\d+\s*(?:of|/)\s*\d+\s*$', re.IGNORECASE),
72
+ re.compile(r'^(?:Page|Pág\.|P\.)\s*\d+\s*$', re.IGNORECASE),
73
+ )
74
+
75
+ _LINE_RULE_GROUPS: dict[str, tuple[int, ...]] = {
76
+ "remove_page_numbers": (0, 1, 2, 3, 4),
77
+ }
78
+
79
+ def __init__(self, config: Optional[AppConfig] = None):
80
+ cfg = config or DEFAULT_CONFIG
81
+ rules = cfg.cleaning_rules
82
+ self._active_line_rules = self._resolve_line_rules(rules)
83
+ self._collapse_empty = bool(rules.get("remove_empty_lines", False))
84
+ self._normalize_spaces = bool(rules.get("normalize_spaces", False))
85
+ self._dedupe_consecutive = bool(rules.get("remove_duplicate_headers", False))
86
+
87
+ def _resolve_line_rules(self, cleaning_rules: dict) -> tuple:
88
+ active: list[int] = []
89
+ for key, indices in self._LINE_RULE_GROUPS.items():
90
+ if cleaning_rules.get(key, False):
91
+ active.extend(indices)
92
+ return tuple(self._RULES[i] for i in active)
93
+
94
+ def clean(self, content: str, **kwargs) -> str:
95
+ if not any((
96
+ self._active_line_rules,
97
+ self._collapse_empty,
98
+ self._normalize_spaces,
99
+ self._dedupe_consecutive,
100
+ )):
101
+ return content
102
+
103
+ lines = content.split('\n')
104
+ # Classify code-block lines ONCE so every rule below can leave code
105
+ # untouched: page markers, blank lines, repeated lines and internal
106
+ # spacing are all significant inside code.
107
+ code_mask = self._code_mask(lines)
108
+
109
+ # Stage 1: line-level rules (page numbers). Lines matching a
110
+ # rule are dropped; everything else keeps its original whitespace.
111
+ if self._active_line_rules:
112
+ lines, code_mask = self._apply_line_rules(lines, code_mask)
113
+
114
+ # Stage 2: collapse empty lines. Run before normalize so that
115
+ # runs of whitespace-only lines (which normalize would turn into
116
+ # empty strings) are deduped exactly once.
117
+ if self._collapse_empty:
118
+ lines, code_mask = self._collapse_empty_lines(lines, code_mask)
119
+
120
+ # Stage 3: normalize spaces within each line. Run before dedupe
121
+ # so that lines differing only in whitespace are treated as
122
+ # duplicates.
123
+ if self._normalize_spaces:
124
+ lines = self._normalize_lines_spaces(lines, code_mask)
125
+
126
+ # Stage 4: drop consecutive duplicate lines.
127
+ if self._dedupe_consecutive:
128
+ lines = self._dedupe_consecutive_lines(lines, code_mask)
129
+
130
+ return '\n'.join(lines)
131
+
132
+ def _code_mask(self, lines: list[str]) -> list[bool]:
133
+ """Boolean per line: True when the line lives inside a code block.
134
+
135
+ Fenced blocks (````` ``` ```` / ``~~~``) are tracked by toggling on
136
+ their delimiter lines; indented blocks are any line with ≥4 leading
137
+ spaces/tabs. Delimiter lines themselves count as code so they are
138
+ never altered or removed by a later rule.
139
+ """
140
+ in_fence = False
141
+ mask: list[bool] = []
142
+ for line in lines:
143
+ if _FENCE_PATTERN.match(line):
144
+ in_fence = not in_fence
145
+ mask.append(True)
146
+ continue
147
+ mask.append(in_fence or self._is_code_line(line))
148
+ return mask
149
+
150
+ def _apply_line_rules(self, lines: list[str], code_mask: list[bool]):
151
+ kept: list[str] = []
152
+ kept_mask: list[bool] = []
153
+ for line, code in zip(lines, code_mask):
154
+ stripped = line.strip()
155
+ if not stripped:
156
+ kept.append(line)
157
+ kept_mask.append(code)
158
+ continue
159
+ if code:
160
+ # Page-number markers inside code are content (e.g. a
161
+ # ``[1]`` array index or a ``Page 1`` literal); never strip.
162
+ kept.append(line)
163
+ kept_mask.append(True)
164
+ continue
165
+ # Normalize mammoth's backslash-escaped markdown punctuation
166
+ # (e.g. ``\[1\]`` → ``[1]``, ``\- 1 \-`` → ``- 1 -``,
167
+ # ``Pág\. 3`` → ``Pág. 3``) so the page-number rules match
168
+ # the real shape of the marker rather than its escaped form.
169
+ normalized = _UNESCAPE_PATTERN.sub(r'\1', stripped)
170
+ if any(rx.match(normalized) for rx in self._active_line_rules):
171
+ continue
172
+ kept.append(line)
173
+ kept_mask.append(code)
174
+ return kept, kept_mask
175
+
176
+ @staticmethod
177
+ def _collapse_empty_lines(lines: list[str], code_mask: list[bool]):
178
+ result: list[str] = []
179
+ result_mask: list[bool] = []
180
+ prev_empty = False
181
+ prev_code = False
182
+ for line, code in zip(lines, code_mask):
183
+ is_empty = not line.strip()
184
+ # Blank lines inside a code block are significant and are not
185
+ # collapsed; blank runs outside code still become one line.
186
+ if is_empty and prev_empty and not (code and prev_code):
187
+ continue
188
+ # Normalize the kept empty line to a bare ``""`` so the
189
+ # output is canonical regardless of whether the input used
190
+ # ``" "`` or ``"\t"`` as its whitespace-only filler.
191
+ result.append("" if is_empty else line)
192
+ result_mask.append(code)
193
+ prev_empty = is_empty
194
+ prev_code = code
195
+ return result, result_mask
196
+
197
+ def _normalize_lines_spaces(self, lines: list[str], code_mask: list[bool]) -> list[str]:
198
+ result: list[str] = []
199
+ for line, code in zip(lines, code_mask):
200
+ result.append(self._normalize_line_spaces(line, code_line=code))
201
+ return result
202
+
203
+ @staticmethod
204
+ def _is_code_line(line: str) -> bool:
205
+ """True when ``line`` starts an indented markdown code block.
206
+
207
+ A line indented by ≥4 spaces/tabs is treated as code by CommonMark,
208
+ where internal spacing is significant. Non-code lines fall through
209
+ to the ordinary whitespace normalization.
210
+ """
211
+ return len(line) - len(line.lstrip(' \t')) >= 4
212
+
213
+ @staticmethod
214
+ def _normalize_line_spaces(line: str, code_line: bool = False) -> str:
215
+ # Convert full-width space (U+3000) used in CJK typography to a
216
+ # regular space so downstream consumers can tokenize uniformly.
217
+ line = line.replace(_FULLWIDTH_SPACE, ' ')
218
+ if not line.strip():
219
+ return line
220
+ if code_line:
221
+ # Inside a fenced or indented code block, internal spacing is
222
+ # meaningful — collapsing it would corrupt the code. Only strip
223
+ # trailing whitespace.
224
+ return line.rstrip()
225
+ # Preserve leading whitespace (indentation matters in markdown
226
+ # for code blocks, list items, tables) while collapsing internal
227
+ # runs of horizontal whitespace and stripping trailing whitespace.
228
+ match = re.match(r'^([ \t]*)(.*?)([ \t]*)$', line, re.DOTALL)
229
+ if not match:
230
+ return line.rstrip()
231
+ leading, middle, _trailing = match.groups()
232
+ middle = _HORIZ_WS_PATTERN.sub(' ', middle)
233
+ return leading + middle
234
+
235
+ @staticmethod
236
+ def _dedupe_consecutive_lines(lines: list[str], code_mask: list[bool]) -> list[str]:
237
+ # Only collapse runs of identical non-empty lines. Empty lines
238
+ # were already handled by ``_collapse_empty_lines``. Two
239
+ # non-adjacent identical lines are kept as-is, and repeated lines
240
+ # inside code blocks are legitimate and kept too.
241
+ result: list[str] = []
242
+ prev: Optional[str] = None
243
+ prev_code = False
244
+ for line, code in zip(lines, code_mask):
245
+ if line.strip() and line == prev and not (code and prev_code):
246
+ continue
247
+ result.append(line)
248
+ prev = line
249
+ prev_code = code
250
+ return result
docconvert/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from docconvert.config import DEFAULT_CONFIG
8
+ from docconvert.controller import ConversionController
9
+ from docconvert.logger import setup_logging
10
+ from docconvert.models import ProgressEvent
11
+
12
+
13
+ def _cli_progress(event: ProgressEvent):
14
+ if event.message:
15
+ print(f'\r[{int(event.progress * 100):3d}%] {event.message:<50s}', file=sys.stderr, end='')
16
+ if event.done:
17
+ print(file=sys.stderr)
18
+
19
+
20
+ class _HelpFormatter(argparse.HelpFormatter):
21
+ """Preserves newlines in description, epilog, and all help strings."""
22
+
23
+ def _fill_text(self, text, width, indent):
24
+ if text:
25
+ return ''.join(indent + line + '\n' for line in text.splitlines())
26
+ return ''
27
+
28
+ def _split_lines(self, text, width):
29
+ return text.splitlines() if text else []
30
+
31
+
32
+ def main_cli(argv: list[str] | None = None) -> int:
33
+ parser = argparse.ArgumentParser(
34
+ description='DocConvert — convert Excel & Word documents to clean Markdown, HTML, or JSON.',
35
+ formatter_class=_HelpFormatter,
36
+ epilog=(
37
+ 'Examples:\n'
38
+ ' python main.py convert input.xlsx --format md\n'
39
+ ' python main.py convert input.docx --format html -o ./output\n'
40
+ ' python main.py convert file1.xlsx file2.xlsx --format json\n'
41
+ ),
42
+ )
43
+
44
+ subparsers = parser.add_subparsers(dest='command', help='Command to run')
45
+
46
+ convert_parser = subparsers.add_parser('convert', help='Convert one or more files')
47
+ convert_parser.add_argument('files', nargs='+', help='Input file paths')
48
+ convert_parser.add_argument(
49
+ '--format', '-f',
50
+ choices=['html', 'md', 'json'],
51
+ default='html',
52
+ help='Output format (default: html)',
53
+ )
54
+ convert_parser.add_argument(
55
+ '--output', '-o',
56
+ default=None,
57
+ help='Output directory (default: same as input file)',
58
+ )
59
+ convert_parser.add_argument(
60
+ '--enhanced', '-e',
61
+ action='store_true',
62
+ help='Enable enhanced Markdown cleaning (remove page numbers, duplicate headers, etc.)',
63
+ )
64
+ convert_parser.add_argument(
65
+ '--sheet', '-s',
66
+ action='append',
67
+ help='Select Excel sheet(s) (repeatable; default: all sheets)',
68
+ )
69
+ convert_parser.add_argument(
70
+ '--verbose', '-v',
71
+ action='store_true',
72
+ help='Enable verbose debug logging',
73
+ )
74
+
75
+ args = parser.parse_args(argv)
76
+
77
+ if args.command != 'convert':
78
+ parser.print_help()
79
+ return 1
80
+
81
+ setup_logging(level='DEBUG' if getattr(args, 'verbose', False) else 'INFO')
82
+
83
+ controller = ConversionController(DEFAULT_CONFIG)
84
+
85
+ all_results: list[tuple[str, str, str | None]] = []
86
+ failed = 0
87
+
88
+ for filepath in args.files:
89
+ p = Path(filepath)
90
+ if not p.exists():
91
+ print(f'Error: file not found — {filepath}', file=sys.stderr)
92
+ all_results.append((p.name, '', 'File not found'))
93
+ failed += 1
94
+ continue
95
+
96
+ ext = p.suffix.lower()
97
+ if ext not in {'.xlsx', '.xls', '.docx', '.doc'}:
98
+ print(f'Error: unsupported format — {filepath}', file=sys.stderr)
99
+ all_results.append((p.name, '', 'Unsupported format'))
100
+ failed += 1
101
+ continue
102
+
103
+ try:
104
+ convert_results = controller.convert_files(
105
+ files=[filepath],
106
+ output_fmt=args.format,
107
+ output_dir=args.output,
108
+ enhanced_md=args.enhanced,
109
+ sheets=args.sheet,
110
+ progress_callback=_cli_progress,
111
+ )
112
+ for name, path, err in convert_results:
113
+ all_results.append((name, path, err))
114
+ if err:
115
+ failed += 1
116
+ print(f'Failed: {name} — {err}', file=sys.stderr)
117
+ else:
118
+ print(f'OK: {name} -> {path}')
119
+ except Exception as e:
120
+ print(f'Error: {filepath} — {e}', file=sys.stderr)
121
+ all_results.append((p.name, '', str(e)))
122
+ failed += 1
123
+
124
+ if failed:
125
+ print(f'\nDone: {len(all_results)} file(s), {failed} failed')
126
+ return 1
127
+
128
+ print(f'\nDone: {len(all_results)} file(s), all succeeded')
129
+ return 0
130
+
131
+
132
+ if __name__ == '__main__':
133
+ sys.exit(main_cli())
docconvert/config.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class AppConfig:
8
+ chunk_size: int = 1000
9
+ max_rows: int = 0
10
+ markdown_style: str = "github"
11
+ preview_chars: int = 5000
12
+ preview_lines: int = 150
13
+ large_file_size: int = 20 * 1024 * 1024
14
+
15
+ cleaning_rules: dict[str, bool] = field(default_factory=lambda: {
16
+ "remove_page_numbers": True,
17
+ "remove_duplicate_headers": True,
18
+ "remove_empty_lines": True,
19
+ "normalize_spaces": True,
20
+ })
21
+
22
+
23
+ DEFAULT_CONFIG = AppConfig()
@@ -0,0 +1,5 @@
1
+ from docconvert.controller.conversion_controller import ConversionController
2
+
3
+ __all__ = [
4
+ "ConversionController",
5
+ ]