Skip to content

Commit 75d976d

Browse files
delphinusclaude
andcommitted
fix: apply kinsoku to ASCII quotes based on surrounding context
ASCII straight quotes (`"`, `'`) are not in the JIS X 4051 kinsoku tables because the same glyph serves both opening and closing roles. As a result, opening quotes could land at line end (e.g. wrapping `(例: "ブラウザ..."` to put the open quote alone before the linebreak). Add `classify_quotes(text)` to label each quote occurrence as "open" or "close" based on surrounding chars — preceded/followed by whitespace, brackets, or punctuation marks an open/close boundary; quotes with both or neither side bounded (e.g. ` " `, or `it's`) are left neutral. Wire the result into the four kinsoku checks in `wrap_words` so opening quotes are treated as NO_BREAK_END (must not end a line) and closing quotes as NO_BREAK_START (must not start a line). Existing behavior for non-quote text is unchanged (verified across 13 patterns x 8 widths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 340210e commit 75d976d

1 file changed

Lines changed: 78 additions & 5 deletions

File tree

lua/md-render/wrap.lua

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,57 @@ for _, ch in ipairs {
5050
NO_BREAK_END[ch] = true
5151
end
5252

53+
--- ASCII straight quotes are ambiguous (open vs. close determined by
54+
--- surrounding characters, since the same glyph serves both roles).
55+
local AMBIGUOUS_QUOTE = { ['"'] = true, ["'"] = true }
56+
57+
--- A character that suggests an adjacent ASCII quote is at a structural
58+
--- boundary (start/end of text, whitespace, or any bracket/punctuation in
59+
--- the kinsoku tables). Used by `classify_quotes` for both prev and next
60+
--- side checks: an opening quote needs an open boundary before it; a
61+
--- closing quote needs a close boundary after it.
62+
---@param ch string|nil
63+
---@return boolean
64+
local function quote_at_boundary(ch)
65+
if ch == nil then return true end
66+
if ch:match "^%s$" then return true end
67+
if NO_BREAK_END[ch] or NO_BREAK_START[ch] then return true end
68+
return false
69+
end
70+
71+
--- Classify each ASCII quote (`"`, `'`) in `text` as "open" or "close"
72+
--- based on surrounding characters. Quotes with both or neither side
73+
--- bounded (e.g. ` " `, or `it's`) are left out of the result table.
74+
---
75+
--- Returned positions are 0-indexed byte offsets into `text`, matching
76+
--- the convention used by `split_segments` and `wrap_words`.
77+
---@param text string
78+
---@return table<integer, "open"|"close">
79+
local function classify_quotes(text)
80+
local roles = {}
81+
local chars = {}
82+
local positions = {}
83+
local pos = 0
84+
for c in text:gmatch "[%z\1-\127\194-\253][\128-\191]*" do
85+
chars[#chars + 1] = c
86+
positions[#positions + 1] = pos
87+
pos = pos + #c
88+
end
89+
90+
for i, c in ipairs(chars) do
91+
if AMBIGUOUS_QUOTE[c] then
92+
local prev_open = quote_at_boundary(chars[i - 1])
93+
local next_close = quote_at_boundary(chars[i + 1])
94+
if prev_open and not next_close then
95+
roles[positions[i]] = "open"
96+
elseif next_close and not prev_open then
97+
roles[positions[i]] = "close"
98+
end
99+
end
100+
end
101+
return roles
102+
end
103+
53104
local has_budoux, budoux = pcall(require, "budoux")
54105
local budoux_parser = has_budoux and budoux.load_default_japanese_parser() or nil
55106
local budoux_model = budoux_parser and budoux_parser.model or nil
@@ -544,6 +595,19 @@ function M.wrap_words(text, max_width)
544595
local last_seg_pos = 0
545596

546597
local segments = split_segments(text)
598+
local quote_roles = classify_quotes(text)
599+
600+
--- True iff `s`, occupying bytes `[byte_start, byte_start + #s)` of `text`,
601+
--- ends with a NO_BREAK_END char or an ambiguous quote classified as "open".
602+
local function ends_no_break_end(s, byte_start)
603+
if s == "" then return false end
604+
local lc = last_char(s)
605+
if NO_BREAK_END[lc] then return true end
606+
if AMBIGUOUS_QUOTE[lc] and quote_roles[byte_start + #s - #lc] == "open" then
607+
return true
608+
end
609+
return false
610+
end
547611

548612
for i, seg in ipairs(segments) do
549613
local seg_width = vim.api.nvim_strwidth(seg.text)
@@ -556,12 +620,19 @@ function M.wrap_words(text, max_width)
556620
-- punctuation (e.g. ".gitignore" starts with "." which is in NO_BREAK_START,
557621
-- but the segment is a filename, not standalone punctuation).
558622
local fc = first_char(seg.text)
559-
local is_no_break_start = NO_BREAK_START[fc] and not (#fc == 1 and #seg.text > 1)
623+
local is_no_break_start
624+
if AMBIGUOUS_QUOTE[fc] then
625+
-- Only treat as NO_BREAK_START when classified as a closing quote.
626+
-- This covers standalone `"` segments while leaving multi-char words
627+
-- like `"hello` (opening quote) alone.
628+
is_no_break_start = quote_roles[seg.byte_pos] == "close"
629+
else
630+
is_no_break_start = NO_BREAK_START[fc] and not (#fc == 1 and #seg.text > 1)
631+
end
560632
-- Guard 追い出し: if prev_current ends with a NO_BREAK_END char (e.g. "(",
561633
-- "「"), pushing it as its own line would strand that opener at line end.
562634
-- Fall through to 追い込み so the line overflows but kinsoku is preserved.
563-
local prev_ends_no_break_end = prev_current ~= ""
564-
and NO_BREAK_END[last_char(prev_current)] or false
635+
local prev_ends_no_break_end = ends_no_break_end(prev_current, current_start)
565636
if is_no_break_start and prev_current ~= "" and not prev_ends_no_break_end then
566637
table.insert(wrapped_lines, prev_current)
567638
table.insert(line_starts, current_start)
@@ -575,7 +646,7 @@ function M.wrap_words(text, max_width)
575646
local sep = (seg.has_leading_space and current ~= "") and " " or ""
576647
current = current .. sep .. seg.text
577648
current_width = current_width + #sep + seg_width
578-
elseif NO_BREAK_END[last_char(current)] then
649+
elseif ends_no_break_end(current, current_start) then
579650
-- Kinsoku: current ends with a NO_BREAK_END char (e.g. "(", "「").
580651
-- Pushing it now would strand the opener at line end. Append seg
581652
-- (overflowing the line) so the opener stays bonded to its content.
@@ -598,7 +669,7 @@ function M.wrap_words(text, max_width)
598669
-- break before it so it doesn't sit at line end.
599670
-- Use >= to be conservative: even if the next segment barely fits, subsequent
600671
-- 追い出し could push it away and strand this char at line end.
601-
if NO_BREAK_END[last_char(seg.text)] and current ~= "" then
672+
if ends_no_break_end(seg.text, seg.byte_pos) and current ~= "" then
602673
local next_seg = segments[i + 1]
603674
local next_width = next_seg and vim.api.nvim_strwidth(next_seg.text) or 0
604675
if current_width + space_width + seg_width + next_width >= max_width then
@@ -650,6 +721,8 @@ end
650721
M.split_ascii_syllables = split_ascii_syllables
651722
M.NO_BREAK_START = NO_BREAK_START
652723
M.NO_BREAK_END = NO_BREAK_END
724+
M.AMBIGUOUS_QUOTE = AMBIGUOUS_QUOTE
725+
M.classify_quotes = classify_quotes
653726
M.split_segments = split_segments
654727
M.is_cjk_or_kinsoku = is_cjk_or_kinsoku
655728
M.first_char = first_char

0 commit comments

Comments
 (0)