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:
2026-09-18 19:01:31 +08:00
parent 898f519773
commit e582198cc8
6 changed files with 181 additions and 13 deletions
+10
View File
@@ -85,6 +85,16 @@ export interface Sentence {
sort: number
content: string
citations: Citation[]
/**
* What to print before this sentence when the paragraph is put back
* together — `''` or a single space, decided by the server.
*
* A paragraph is its sentences concatenated, and the seam matters: Chinese
* needs nothing after 「。」 while English needs a space, or `First.` followed
* by `Second.` reads as `First.Second.`. The server owns the rule so that a
* reader, a preview and a future export cannot each space it differently.
*/
separator_before: string
created_at: string
updated_at: string
}
@@ -8,9 +8,10 @@
* renders its heading and a quiet placeholder, because the paper's shape
* comes from its template and an unwritten paragraph is still a paragraph.
* 2. **A paragraph is reassembled from its sentences.** They are printed in
* `sort` order, one after another, with no separator added — Chinese
* sentences already end in punctuation, and inserting anything between them
* would show up in the text.
* `sort` order, and the only thing between them is the server's
* `separator_before` — a space for English, nothing for Chinese. This
* component adds no spacing of its own, because a client that guessed would
* space one paragraph differently from the next reader.
* 3. **Nothing but the writing is printed.** No position numbers, no ids, no
* counts — this is the paper, not the editor. `sort` is a writing detail and
* the places that show it are the paragraph dialog (its title and every
@@ -92,7 +93,9 @@ const anchor = computed(() => `paragraph-${props.paragraph.paper_template_filed_
<p v-if="hasContent" class="paragraph-body">
<template v-for="sentence in paragraph.sentences" :key="sentence.id">
<span class="sentence">{{ sentence.content }}</span>
<!-- The separator comes from the server: it is a space for English
and nothing for Chinese, and only the server knows which. -->
<span class="sentence">{{ sentence.separator_before }}{{ sentence.content }}</span>
<el-tooltip
v-for="citation in sentence.citations"
:key="citation.id"