Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ backend → URL string or LibreChat artifact dict. Details:

**Word**
- One line-break model: every newline reaching the inline layer is a soft
break; two-space runs are assembled by `_soft_break_run()` in the block
dispatcher and stop before any block. Never give `expand_br_to_block_breaks()`
break; a line ending in two spaces or a `<br>` (`ends_with_soft_break()`)
continues into the next one — runs are assembled by `_soft_break_run()` in
the block dispatcher and stop at a blank line and before any block. Turn
`<br>` into newlines with `br_to_newlines()`, which reads `<br>` + newline
as one break, never with a bare `_BR_RE.sub()`. Never give `expand_br_to_block_breaks()`
the renderer's numbering state (#110). Details in
[word.md](docs/development/tools/word.md#one-line-break-model).

Expand Down
32 changes: 24 additions & 8 deletions docs/development/tools/word.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ markdown_content
├─ <!-- directive --> → collected, then applied to the next block
├─ other <!-- --> → skipped
└─ anything else → _soft_break_run() → paragraph + inline parse
(a line ending in two spaces continues the paragraph,
stopping before any line that starts a block)
(a line ending in two spaces or a <br> continues the
paragraph, stopping before any line that starts a block)
▼
inline_formatting.parse_inline_formatting(text, paragraph) every text span
├─ decode a fixed list of safe HTML entities
├─ <br>, CR, literal "\n" → "\n"; split, rstrip, one <w:br/> per break
├─ <br>, CR, literal "\n" → "\n" (a <br> + newline pair is one); split,
│ rstrip, one <w:br/> per break
├─ backslash escapes → private-use placeholders, restored after tokenising
└─ inline_markdown.build_inline_pattern().split() → runs, hyperlinks
▼
Expand All @@ -83,7 +84,7 @@ line by line and paragraphs are appended as they are recognised.
|--------|------|
| `base_docx_tool.py` | Template load, metadata, TOC, header/footer, then hands off to the processor. The three entry points |
| `markdown_processor.py` | Line walker (`process_markdown_content`), block dispatcher (`process_markdown_block`), soft breaks, code blocks, alignment blocks, comment directives, the running ordered-list count |
| `patterns.py` | Every compiled regex for block detection, plus `ordered_list_is_genuine()`, `normalize_newlines()`, `expand_br_to_block_breaks()`, `line_starts_block()`, `contains_block_markdown()` |
| `patterns.py` | Every compiled regex for block detection, plus `ordered_list_is_genuine()`, `normalize_newlines()`, `ends_with_soft_break()`, `br_to_newlines()`, `expand_br_to_block_breaks()`, `line_starts_block()`, `contains_block_markdown()` |
| `inline_formatting.py` | `parse_inline_formatting()`: entities, escapes, soft breaks, run creation, hyperlinks |
| `block_elements.py` | Tables, lists, images, horizontal lines, alignment detection |
| `numbering.py` | Ordered-list restart through fresh `<w:num>` instances; style-aware numbering resolution; indent re-assertion |
Expand Down Expand Up @@ -253,11 +254,25 @@ Since #110 there is one rule for every entry point: the block layer decides
which newlines separate blocks, and **every newline that reaches the inline
layer is a soft break** (`<w:br/>`). `parse_inline_formatting()` folds
`<br>`, a CR and a literal `\n` into `\n`, splits on it and strips the
trailing-space marker from each segment. A line ending in two spaces is the
source spelling of the same break: `_soft_break_run()` in the block
trailing-space marker from each segment. A line ending in two spaces **or in
a `<br>`** is the source spelling of the same break
(`patterns.ends_with_soft_break()`): `_soft_break_run()` in the block
dispatcher joins such lines with real newlines, for prose and for quotes,
and stops before any line for which `patterns.line_starts_block()` is true
so a list or table on the next line is never swallowed into the paragraph.
and stops at a blank line and before any line for which
`patterns.line_starts_block()` is true so a list or table on the next line is
never swallowed into the paragraph. `process_list_items()` asks the same
question of a list item and pulls a following line into the item unless it is
blank, a list marker or another block (`_continues_item()`). A heading is
always one line.

A `<br>` that ends a line is left in the text the block layer joins, so the
inline layer sees `<br>` followed by `\n` — the same break spelled twice.
`patterns.br_to_newlines()`, which the inline layer and
`set_header_footer()` both use, turns that pair into one `\n` and every
other `<br>` into its own, so `<br><br>` plus Enter is still an empty line.
Before this a line ending in `<br>` became its own paragraph, with the
style's paragraph spacing after it, and the `<br>` vanished as a trailing
break — the usual way a model writes a signature block.
`line_starts_block()` is also the predicate behind `contains_block_markdown()`,
which routes template placeholder values.

Expand Down Expand Up @@ -416,6 +431,7 @@ it on open.
| `tests/test_docx_style_map.py`, `test_docx_style_numbering.py`, `test_docx_style_tag.py` | Style mapping, style-aware numbering, the `style` directive |
| `tests/test_docx_escaped_newlines.py` | Literal `\n` and backslash escapes |
| `tests/test_docx_soft_breaks.py` | The line-break model: `<br>`, trailing spaces, CR, runs stopping before blocks, quotes, cells, headers |
| `tests/test_docx_br_before_newline.py` | A `<br>` ending a line is one soft break: paragraphs, quotes, list items, placeholders, headers; a blank line after it is a new paragraph |
| `tests/test_docx_templates.py`, `test_docx_placeholder_formatting.py`, `test_docx_conditionals.py` | Dynamic templates: placeholder replacement across runs, formatting preservation, conditionals |
| `tests/test_docx_content_controls.py`, `test_docx_content_controls_phase2.py`, `test_docx_template_content_controls.py` | Dynamic templates: content controls filled by Tag |
| `tests/test_docx_warnings.py` | The warnings channel: every code, the source line, de-duplication, the tool-boundary response shape |
Expand Down
5 changes: 4 additions & 1 deletion docs/markdown-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,14 @@ Nesting and combinations work, e.g. `**bold with *italic* inside**`, `**~~bold s
| blank line | New paragraph / next block |
| `<br>` | **Soft line break** — a new line inside the *same* paragraph (`Street<br>City`) |
| two trailing spaces | The same soft break. Invisible, and many editors strip them — prefer `<br>` |
| `<br>` at the end of a line | Still **one** soft break: the next line continues the same paragraph or list item (`Street<br>` ⏎ `City`). Follow the `<br>` with a blank line and the next line is a new paragraph |
| a literal `\n` typed as text | Read as a real newline, but never write it deliberately |

A heading is always one line: a `<br>` or two spaces at its end do not pull the next line into it.

A soft break never swallows what follows it: a list, table, heading, quote or page break on the next line is still that block. A `<br>` written *before* a list (`Intro:<br>1. A<br>2. B`) is promoted to a real line break so the list renders as one — which is why a line-leading number that is really text (`1. máje 5`) needs the documented `1\.` escape.

> ⚠️ **One thing `<br>` cannot do:** resume a numbered list that started earlier in the document. `Poznámka<br>3. Třetí`, after a list that reached `2.`, stays one paragraph with "3." as text. Write that continuation on its own line (a blank line, or a line ending in two spaces) and it becomes item 3 — [list continuation](#word) across intervening content is unaffected, it just needs a real line break rather than a `<br>`.
> ⚠️ **One thing `<br>` cannot do:** resume a numbered list that started earlier in the document. `Poznámka<br>3. Třetí`, after a list that reached `2.`, stays one paragraph with "3." as text. Write that continuation on its own line (after a blank line, a line ending in two spaces, or a `<br>` followed by Enter) and it becomes item 3 — [list continuation](#word) across intervening content is unaffected, it just needs a real line break rather than a `<br>`.

`header_text` / `footer_text` take `<br>` too; they are plain text, not markdown.

Expand Down
31 changes: 30 additions & 1 deletion docx_tools/block_elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
UNORDERED_LIST_CAPTURE_PATTERN,
_ALIGN_INLINE_RE,
_ALIGN_OPEN_RE,
_ALIGN_CLOSE_RE,
_BR_PARAGRAPH_RE,
ends_with_soft_break,
line_starts_block,
)
from .inline_formatting import parse_inline_formatting
from .numbering import (
Expand Down Expand Up @@ -227,6 +230,26 @@ def add_table_to_doc(table_data, doc, col_alignments=None, borderless=False,
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
def _continues_item(lines, idx) -> bool:
"""True if ``lines[idx]`` may continue the list item above it.
The item above ended in a soft-break marker; the next line joins it unless it
is blank, or starts something of its own — any list marker (a sibling or
nested item; in a list a number is never read as a date), any other block
(:func:`~docx_tools.patterns.line_starts_block`), a comment or directive, or
the close of an alignment block.
"""
stripped = lines[idx].strip()
return bool(
stripped
and not ORDERED_LIST_PATTERN.match(stripped)
and not UNORDERED_LIST_PATTERN.match(stripped)
and not stripped.startswith('<!--')
and not _ALIGN_CLOSE_RE.match(stripped)
and not line_starts_block(lines, idx)
)


def process_list_items(lines, start_idx, doc, is_ordered=False, level=0,
return_elements=False, number_styles=None, bullet_styles=None,
base_indent=None, ordered_run=None, warnings=None):
Expand Down Expand Up @@ -347,12 +370,18 @@ def process_list_items(lines, start_idx, doc, is_ordered=False, level=0,
style_ind_resolved = True
apply_style_indent(paragraph, style_ind_attrs)
last_item_number = item_number
i += 1
# An item continues across soft breaks as a paragraph does: a line ending
# in two spaces or a <br> pulls the next line into this item.
while ends_with_soft_break(original_line) and i < n and _continues_item(lines, i):
item_text = item_text.rstrip() + '\n' + lines[i].strip()
original_line = lines[i]
i += 1
parse_inline_formatting(item_text, paragraph)
items_emitted += 1
if return_elements:
elements.append(paragraph._p)
doc._body._body.remove(paragraph._p)
i += 1
# Look ahead for nested items
while i < n:
next_original = lines[i]
Expand Down
7 changes: 4 additions & 3 deletions docx_tools/document_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from docx.oxml.ns import qn
from docx.enum.text import WD_ALIGN_PARAGRAPH
from template_utils import find_docx_template
from .patterns import _BR_RE, _PAGE_TOKEN_RE, normalize_newlines
from .patterns import _PAGE_TOKEN_RE, br_to_newlines, normalize_newlines
logger = logging.getLogger(__name__)
def load_templates():
"""Resolve Word template path from custom/default template directories.
Expand Down Expand Up @@ -50,8 +50,9 @@ def set_header_footer(doc, text, kind='header'):
"""
_TOKEN_MAP = {'{page}': 'PAGE', '{pages}': 'NUMPAGES'}
# python-docx turns a "\n" inside run text into a <w:br/>, so folding every
# break spelling into a newline is all this needs.
text = _BR_RE.sub('\n', normalize_newlines(text or ''))
# break spelling into a newline is all this needs (a <br> followed by a
# newline is one break, not two).
text = br_to_newlines(normalize_newlines(text or ''))
def _fill_paragraph(p, content):
"""Clear existing runs/fields and write *content* into paragraph *p*."""
existing_alignment = p.alignment
Expand Down
8 changes: 4 additions & 4 deletions docx_tools/inline_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from inline_markdown import is_safe_link_target
from .patterns import (
_INLINE_FORMAT_RE, _LINK_RE, _ESCAPE_RE, _BR_RE, normalize_newlines,
_INLINE_FORMAT_RE, _LINK_RE, _ESCAPE_RE, br_to_newlines, normalize_newlines,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -108,9 +108,9 @@ def parse_inline_formatting(text, paragraph, bold=False, italic=False):
# Fold every newline spelling (a literal "\n", a CR) into a real newline, and
# <br> with them: by the time text reaches this layer the block parser has
# already taken out the newlines that separate paragraphs, so every one left
# here is a soft break. One rule, one marker.
text = normalize_newlines(text)
text = _BR_RE.sub('\n', text)
# here is a soft break. One rule, one marker — and one break for a <br> the
# writer followed with a newline, not two.
text = br_to_newlines(normalize_newlines(text))
escape_ctx = {"map": {}, "counter": 0}
text = _handle_escapes(text, escape_ctx)
segments = text.split('\n')
Expand Down
24 changes: 15 additions & 9 deletions docx_tools/markdown_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
BLOCKQUOTE_PATTERN,
COMMENT_DIRECTIVE_PATTERN,
CODE_FENCE_PATTERN,
SOFT_BREAK_SUFFIX,
ends_with_soft_break,
_ALIGN_CLOSE_RE,
line_starts_block,
ordered_list_is_genuine,
Expand Down Expand Up @@ -168,15 +168,21 @@ def _add_heading(doc, level, content, style_map, warnings=None):
def _soft_break_run(lines, start_idx, ordered_run=None, strip_quote=False):
"""Collect the soft-break run that starts at ``lines[start_idx]``.
A line ending in two spaces continues into the next one *within the same
paragraph*. The run stops at a blank line, at a line that does not itself end
in the marker, and — crucially — before any line that begins a block element:
a list, table, heading, quote, image, page break, alignment tag or comment. Without
that guard the block would be swallowed into the paragraph and rendered as
literal text (its first list item, its header row, its ``---``).
A line ending in a soft-break marker — two spaces or a ``<br>``
(:func:`~docx_tools.patterns.ends_with_soft_break`) — continues into the next
one *within the same paragraph*. The run stops at a blank line (so
``<br>`` followed by a blank line still starts a new paragraph), at a line
that does not itself end in a marker, and — crucially — before any line that
begins a block element: a list, table, heading, quote, image, page break,
alignment tag or comment. Without that guard the block would be swallowed
into the paragraph and rendered as literal text (its first list item, its
header row, its ``---``).
The marker spaces are stripped from every line; the caller gets the lines
joined by plain newlines, which the inline layer renders as ``<w:br/>``.
joined by plain newlines, which the inline layer renders as ``<w:br/>``. A
trailing ``<br>`` is left in place: the inline layer reads ``<br>`` plus the
newline after it as one break, not two
(:func:`~docx_tools.patterns.br_to_newlines`).
*strip_quote* drops the leading ``>`` of each line, for a block quote whose
lines are joined by soft breaks. A continuation line may omit its ``>``
Expand All @@ -193,7 +199,7 @@ def _soft_break_run(lines, start_idx, ordered_run=None, strip_quote=False):
line = raw.strip()
collected.append(line.lstrip('>').strip() if strip_quote else line)
idx += 1
if not raw.endswith(SOFT_BREAK_SUFFIX):
if not ends_with_soft_break(raw):
break
if idx >= n or not lines[idx].strip():
break
Expand Down
33 changes: 33 additions & 0 deletions docx_tools/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@
# into the next line within the same paragraph.
SOFT_BREAK_SUFFIX = ' '

# A <br> that ends a line. Followed by a newline it is ONE soft break, not two:
# the <br> says "break here" and the newline is only where the writer pressed
# Enter.
_TRAILING_BR_RE = re.compile(r'<br\s*/?>[ \t]*$', re.IGNORECASE)
_BR_BEFORE_NEWLINE_RE = re.compile(r'<br\s*/?>[ \t]*\n', re.IGNORECASE)


# ---------------------------------------------------------------------------
# Utility
Expand Down Expand Up @@ -123,6 +129,25 @@ def normalize_newlines(text: str) -> str:
return _NEWLINE_RE.sub('\n', text)


def ends_with_soft_break(line: str) -> bool:
"""True if *line* ends in a soft-break marker: two spaces, or a ``<br>``.

Such a line continues into the next one within the same paragraph (or list
item). Both spellings mean the same thing, so the block parser asks this one
question wherever it decides whether a newline ends the paragraph.
"""
return line.endswith(SOFT_BREAK_SUFFIX) or bool(_TRAILING_BR_RE.search(line))


def br_to_newlines(text: str) -> str:
"""Turn every ``<br>`` in *text* into ``\\n``, once per break.

A ``<br>`` already followed by a newline is the same break written twice, so
the pair becomes one ``\\n``; every other ``<br>`` becomes its own.
"""
return _BR_RE.sub('\n', _BR_BEFORE_NEWLINE_RE.sub('\n', text))


def ordered_list_is_genuine(lines, idx) -> bool:
"""Return True if the ordered-list marker at ``lines[idx]`` should start a list.

Expand Down Expand Up @@ -233,8 +258,16 @@ def expand_br_to_block_breaks(text: str) -> str:
out.append(line)
continue
segments = [seg.strip() for seg in _BR_RE.split(line)]
# A <br> that ends the line is the soft-break marker for the next line,
# not a split point: keep it on the last segment, or the split would turn
# it into a blank line and cut the next line off the last item.
trailing_br = len(segments) > 1 and not segments[-1]
if trailing_br:
segments.pop()
if len(segments) > 1 and any(_segment_is_block(segments, idx)
for idx in range(1, len(segments))):
if trailing_br:
segments[-1] += '<br>'
out.extend(segments)
else:
out.append(line)
Expand Down
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ async def create_word_document(
"- <center>text</center> for a single line; for multiple lines put <center> and </center> each on their own line with the content on the lines between\n"
"- <div align=\"right|center|justify|left\">text</div> (single or multi-line)\n"
"\n"
"LINE BREAKS: Use real newlines in the text — separate paragraphs and blocks with a blank line. For a soft break (a new line inside the SAME paragraph, e.g. an address block) write <br>: Street<br>City. Ending a line with two trailing spaces does the same, but the spaces are invisible and many editors strip them, so prefer <br>. Both work anywhere text is written — prose, headings, list items, quotes, table cells. Do NOT type the two literal characters backslash-n to mean a newline.\n"
"LINE BREAKS: Use real newlines in the text — separate paragraphs and blocks with a blank line. For a soft break (a new line inside the SAME paragraph, e.g. an address block) write <br>: Street<br>City. Ending a line with two trailing spaces does the same, but the spaces are invisible and many editors strip them, so prefer <br>. Both work anywhere text is written — prose, headings, list items, quotes, table cells. A <br> at the end of a line followed by a newline is still ONE soft break (the next line stays in the same paragraph or list item); put a blank line after it to start a new paragraph instead. Do NOT type the two literal characters backslash-n to mean a newline.\n"
"\n"
"CONVENTIONS:\n"
"- Do NOT confuse --- (page break) with *** (horizontal line).\n"
Expand Down
Loading
Loading