Skip to content

Commit 7913f82

Browse files
bennytsai1234claude
andcommitted
fix(reader): B2 末行補償 off-by-one——近滿末行不再把末字擠成孤行
letterSpacing 加在範圍內每個字元之後(字數份),安全上限分母卻用 間隙數(字數-1),總增量最多超出 headroom 一個 spacing,近滿末行 的末字會被擠到下一行。分母改為末行字元數並加回歸測試。 chore(release): bump version to 0.2.137+151 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 841e8dc commit 7913f82

5 files changed

Lines changed: 77 additions & 2 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# B2 末行補償 off-by-one — 近滿末行末字回捲修正
2+
3+
層級:T0/T1(單點數學修正,探針已實證,回歸測試已加)
4+
5+
## Before
6+
7+
`layout_pump.dart` 末行補償的安全上限以 `headroom ÷ 間隙數(字數−1)`
8+
計算,但 letterSpacing 加在範圍內每個字元之後(字數份),總增量
9+
= spacing × 字數,最多超出 headroom 一個 spacing。末行接近滿行時,
10+
末字被擠到下一行成孤行(探針:9/9/9 三行段落變四行)。
11+
觸發條件:「末行字距補償」開啟(預設關)+末行近滿。
12+
13+
## After
14+
15+
分母改為末行字元數(`lastLineBoxes.length`),總增量必 ≤ headroom,
16+
斷行不再改變。新增回歸測試(hybrid_pump_test:27 字 9/9/9、headroom
17+
1px,斷言行數維持 3 且末行寬 ≤ 內容寬)。
18+
19+
驗證:`flutter analyze` 無問題;`flutter test` 734 全過。
20+
隨 v0.2.137 發布。
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# 2026-07-13 工作摘要
22

33
- [justify-indent-placeholder](justify-indent-placeholder.md) — 修正 justify 折疊行首 U+3000 縮排造成 soft-wrap 行字距異常放大、單行/多行段落間距不一致;縮排改用 placeholder 呈現,並升級 metrics 快取版本。
4+
- [b2-lastline-offbyone](b2-lastline-offbyone.md) — 修正末行補償安全上限的 off-by-one(分母間隙數→字數),近滿末行不再把末字擠成孤行;隨 v0.2.137 發布。

lib/features/reader_v2/hybrid/pump/layout_pump.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,12 @@ final class LayoutPump implements HybridLayoutPump {
244244
textAlignOverride: task.textStyle.textAlign,
245245
);
246246
}
247+
// letterSpacing 加在範圍內每個字元之後(含末字),總增量是
248+
// spacing × 字數而非 × 間隙數;分母若用 gaps,近滿末行會超寬
249+
// 一個 spacing 而把末字擠到下一行。
247250
final lastLineHeadroom =
248251
(task.contentWidth - lines[lastLineIndex].width) /
249-
lastLineGaps.toDouble();
252+
lastLineBoxes.length.toDouble();
250253
final safeExtraLetterSpacing =
251254
extraLetterSpacing
252255
.clamp(0.0, lastLineHeadroom > 0 ? lastLineHeadroom : 0.0)

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: night_reader
22
description: "夜讀 - Night Reader, a Flutter novel reader app"
33
publish_to: 'none'
4-
version: 0.2.136+150
4+
version: 0.2.137+151
55
homepage: https://github.com/bennytsai1234/night-reader
66
repository: https://github.com/bennytsai1234/night-reader
77
issue_tracker: https://github.com/bennytsai1234/night-reader/issues

test/features/reader_v2/hybrid/hybrid_pump_test.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,57 @@ void main() {
220220
baselineCache.dispose();
221221
});
222222

223+
test('B2 末行補償:近滿末行不得把末字擠到下一行', () async {
224+
final store = MeasurementStore();
225+
final cache = ParagraphCache();
226+
final fingerprint = _fingerprint(lastLineSpacingCompensation: true);
227+
final pump = LayoutPump(
228+
paragraphCache: cache,
229+
measurementStore: store,
230+
namespace: MeasurementNamespace(
231+
epoch: LayoutEpoch.initial,
232+
fingerprint: fingerprint,
233+
),
234+
);
235+
const key = BlockKey(chapterIndex: 0, blockIndex: 0);
236+
const fontSize = 20.0;
237+
// 27 個無標點字元 + 寬 9 字 + 1px 殘餘 → 9/9/9 三行;末行 headroom
238+
// 僅 1px。補償上限若誤用間隙數(8)作分母,總增量 9×0.125 會超寬
239+
// 而把末字擠成第四行孤行。
240+
final text = '夜' * 27;
241+
pump.submit(
242+
LayoutTask(
243+
block: ChapterBlock(
244+
key: key,
245+
text: text,
246+
charRange: const HybridTextRange(0, 27),
247+
sourceParagraphIndex: 0,
248+
),
249+
epoch: LayoutEpoch.initial,
250+
fingerprint: fingerprint,
251+
textStyle: const HybridBlockTextStyle(
252+
fontSize: fontSize,
253+
lineHeight: 1.5,
254+
letterSpacing: 0,
255+
textAlign: ui.TextAlign.justify,
256+
),
257+
contentWidth: fontSize * 9 + 1,
258+
),
259+
);
260+
261+
expect(await pump.pumpPending(), 1);
262+
final paragraph = cache.acquire(key, LayoutEpoch.initial)!;
263+
final lines = paragraph.computeLineMetrics();
264+
expect(lines.length, 3, reason: '補償不得改變斷行(末字回捲即為 off-by-one)');
265+
expect(
266+
lines.last.width,
267+
lessThanOrEqualTo(fontSize * 9 + 1 + 0.01),
268+
reason: '末行寬不得超出內容寬',
269+
);
270+
pump.dispose();
271+
cache.dispose();
272+
});
273+
223274
test('justify 下段首縮排以 placeholder 保留原寬,字距不吸收縮排寬度', () async {
224275
final store = MeasurementStore();
225276
final cache = ParagraphCache();

0 commit comments

Comments
 (0)