backend: decide the seam between sentences instead of gluing them
A paragraph is its sentences concatenated. Nothing was put between them, which is right for Chinese — 「。」 already separates — and wrong for English, where `Adaptive capacity rises.` followed by `Relocation follows.` printed as `...rises.Relocation...`: a sentence boundary the reader cannot see. CJK output hid it, so it would have surfaced as an English bug later rather than now. `sentence_separator` now returns "" or a single space per seam, and the document carries it as `SentenceRead.separator_before`. The rule is about the seam, not the language: a space goes in unless both sides are CJK. Mixed seams take the space. Empty sentences take none. It is derived on every read and never stored, so it cannot drift from the text, and the client prints `separator_before + content` and adds no spacing of its own. No splitting was added anywhere, and none exists: a sentence is one line in the editor and nothing parses it. The single split this project has ever performed was the one-time move of the old abstract column, which cut after 「。」 only — conservative on purpose, since an English abstract is better left in one row than cut at the first `et al.`. Five smoke checks cover the seam (Chinese, English, the assembled paragraph, and that no separator is stored inside the content). 45 checks pass.
This commit is contained in:
@@ -227,6 +227,60 @@ def read(paper: Paper) -> PaperRead:
|
||||
return PaperRead(**item.model_dump())
|
||||
|
||||
|
||||
# --- how a paragraph reads ---------------------------------------------------
|
||||
|
||||
#: Code-point ranges that count as CJK when deciding what belongs at the seam
|
||||
#: between two sentences: ideographs, kana, CJK punctuation (。、「」…) and the
|
||||
#: fullwidth forms.
|
||||
_CJK_RANGES: tuple[tuple[int, int], ...] = (
|
||||
(0x3000, 0x303F),
|
||||
(0x3040, 0x30FF),
|
||||
(0x3400, 0x4DBF),
|
||||
(0x4E00, 0x9FFF),
|
||||
(0xF900, 0xFAFF),
|
||||
(0xFF00, 0xFF60),
|
||||
(0x20000, 0x2FA1F),
|
||||
)
|
||||
|
||||
|
||||
def _is_cjk(character: str) -> bool:
|
||||
"""Whether ``character`` is CJK, by code point."""
|
||||
code = ord(character)
|
||||
return any(low <= code <= high for low, high in _CJK_RANGES)
|
||||
|
||||
|
||||
def sentence_separator(previous: str, current: str) -> str:
|
||||
"""What belongs between two sentences when a paragraph is put back together.
|
||||
|
||||
A paragraph is printed by concatenating its sentences in ``sort`` order, so
|
||||
something has to decide what goes at the seam. Chinese needs nothing: the
|
||||
full stop already separates, and a space between 「。」 and the next
|
||||
character is wrong. English needs a space, or ``"First."`` followed by
|
||||
``"Second."`` prints as ``First.Second.`` — a boundary the reader cannot
|
||||
see, and the one way this model could actually break an English paper.
|
||||
|
||||
So the rule is about the seam, not about the language: a space goes in
|
||||
unless **both** sides are CJK, where adjacency is the convention. A mixed
|
||||
seam — 「……缺口。」 + ``This study…``, ``test.`` + 「本研究……」 — takes the
|
||||
space, which is what a bilingual manuscript wants.
|
||||
|
||||
Nothing about this is stored. The separator is derived on every read, so it
|
||||
cannot drift from the text it separates, and it is recomputed for free when
|
||||
a sentence is edited.
|
||||
|
||||
Empty sentences take no separator: an empty line that carries only a
|
||||
citation has no text to separate from.
|
||||
"""
|
||||
if not previous or not current:
|
||||
return ""
|
||||
if previous[-1].isspace() or current[0].isspace():
|
||||
# The writer's own spacing wins, though trimming makes this rare.
|
||||
return ""
|
||||
if _is_cjk(previous[-1]) and _is_cjk(current[0]):
|
||||
return ""
|
||||
return " "
|
||||
|
||||
|
||||
# --- document assembly -------------------------------------------------------
|
||||
|
||||
|
||||
@@ -290,6 +344,12 @@ def build_document(db: Session, paper: Paper) -> PaperDocumentRead:
|
||||
)
|
||||
sentences = [SentenceRead.model_validate(row) for row in rows]
|
||||
|
||||
# What goes between one sentence and the next is decided here rather
|
||||
# than by whoever prints them, so a reader, an export and a preview
|
||||
# cannot each invent their own spacing.
|
||||
for earlier, later in zip(sentences, sentences[1:]):
|
||||
later.separator_before = sentence_separator(earlier.content, later.content)
|
||||
|
||||
paragraph = ParagraphRead(
|
||||
paper_template_filed_sort=position,
|
||||
template_field_id=placement.id if placement is not None else None,
|
||||
|
||||
@@ -167,6 +167,11 @@ class SentenceRead(BaseModel):
|
||||
sort: int
|
||||
content: str
|
||||
citations: list[CitationRead]
|
||||
#: Text to print before this sentence when the paragraph is put back
|
||||
#: together — ``""`` or a single space, never anything else. Derived on
|
||||
#: read by :func:`app.crud.paper.sentence_separator`, never stored, because
|
||||
#: a separator that is stored is a separator that can go stale.
|
||||
separator_before: str = ""
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
Reference in New Issue
Block a user