Skip to content
Open
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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`replace_text` / `replace_text_batch` 現在說明該用哪個勾選字元**(#189)。把表單的 `□`
換成 `☑` 會產生一份文字層完全正確、外觀完全錯誤的文件:逐格比對全過,但沒有任何實測字型
帶有 U+2611 的字形,渲染器改用彩色 emoji 字體,勾選框於是與表單其餘部分格格不入。工具沒有
動過 run 的字型宣告——**被換掉的是實際繪製的字型**,而那一層文字比對看不到。

指引改推 `■`(U+25A0)。**不限 CJK 字型**:實測 U+2611 在 Times New Roman 與 Arial 同樣缺字形,
把這條寫成 CJK 的注意事項等於告訴呼叫者拉丁字型的表單是安全的。

- **`GlyphCoverage` / `GlyphCoverageProbe`(內部)**(#189)。回答「某字元在某個*宣告*字型的
cmap 裡有沒有字形」,答案是**三值**而非布林:`hasGlyph(resolvedFont:)` /
`noGlyph(resolvedFont:)` / `unknown(declaredFont:reason:)`。

`unknown` 是這個設計的重點,而它有**兩種**來源、都會靜默給出反向答案:

1. **字型未安裝**。`CTFontCreateWithName` 遇到未安裝字型不會失敗,它回傳 Helvetica——而
Helvetica 連 `■` 都沒有字形。兩值探測因此會對「`■` 在某個未安裝的 CJK 字型中」回答
「無字形」,**指控的正是本文件建議使用的那個字元**。
2. **解析到別的字型家族**。`CTFontDescriptorCreateMatchingFontDescriptor` 在
`mandatoryAttributes` 為 `nil` 時是「找最佳匹配」而非查表——實測請求 `System Font` 會拿到
`.SF NS` 家族。對它的判決不是對宣告字型的判決,所以也必須回 `unknown`,並具名它落在哪個
家族以便稽核。

兩個已決case 因此都攜帶**實際被量測的家族名**;沒有它,呼叫端分不出「對宣告字型的判決」與
「對 CoreText 認為夠接近的東西的判決」,而那個差別正是 `unknown` 存在的全部理由。

接受性以字型的**名稱集合**(family / full / PostScript / 本機化 family)比對,而非
`CTFontCopyFamilyName` 字面比較:後者以英文作答,會把宣告為 `標楷體` 的已安裝字型判成不存在。

**能力邊界寫在型別名稱與註解裡**:這是 nominal cmap 覆蓋查詢,**不是**對渲染結果的預測。
shaping、normalization、GSUB、variation selector 序列(`☑️` 是 scalar **對**)都在這一層之上;
`hasGlyph` 也不排除渲染器因 emoji presentation 改用別的字面。它另外只收**一個**字型名,而
OOXML 一個 run 宣告四個(`ascii`/`hAnsi`/`eastAsia`/`cs`),哪個生效取決於字元 script——
#189 的 CJK 表單走的是 `eastAsia`。**選 slot 與解析 style/theme 繼承是呼叫端的責任,此處不做。**

### 誠實邊界

**沒有任何呼叫端使用這個探測。** 三種接法(附加在回傳字串、獨立 advisory 工具、專用
`toggle_checkbox`)都會新增或改變對外的 MCP tool surface,那是需要人決定的事;其中「附加在
回傳字串」另外還卡在 #192——工具回傳目前沒有 advisory 通道,既有的 `Warning:` 只寫 stderr,
呼叫端根本看不到。

探測本身也只能量到**本機**的字型集。文件是在讀者的機器上、用讀者的字型渲染的,所以每個答案
都是建議性質;`unknown` 會是常態而非例外——診斷這個 issue 的機器上,PMingLiU 系列的三種寫法
全部無法解析。真正降低風險的是改用 `■`,探測只能把「可能出事」提早講出來。

## [4.0.5] - 2026-08-20

### Fixed
Expand Down
163 changes: 163 additions & 0 deletions Sources/CheWordMCP/GlyphCoverage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import CoreGraphics
import CoreText
import Foundation

/// Why a coverage question could not be answered.
///
/// Separate cases because they call for different things to be said. A run
/// that declared nothing needs style/theme resolution, which this probe does
/// not do; a font that is not installed needs a different machine to answer;
/// a name that landed on some other family needs the caller to know *which*,
/// or the substitution is unauditable.
enum GlyphCoverageUnknownReason: Equatable, Sendable {
/// No font name was declared. In OOXML the run inherits from its style or
/// the theme — neither of which this probe follows.
case noDeclaredFont
/// The declared family is not present on this machine.
case notInstalled
/// A font matched, but it belongs to a different family than the one
/// declared. Carries the family actually matched.
case resolvedToDifferentFamily(String)
}

/// Whether a character can be drawn in a run's *declared* font.
///
/// Three values rather than a `Bool`, because "the font has no glyph for this"
/// and "nothing was measured" are different claims and only one of them
/// justifies warning a caller. Collapsing them is not a cosmetic loss — it
/// inverts the advice. `CTFontCreateWithName` answers an uninstalled family
/// with Helvetica instead of failing, and Helvetica carries no `□`/`■`, so a
/// two-valued probe asked about `■` in an absent CJK family reports "no glyph"
/// for the very character callers are told to use.
///
/// The two decided cases carry the family that was actually measured. Without
/// it a caller cannot tell a verdict about the declared font from a verdict
/// about whatever CoreText decided was close enough — and that difference is
/// the whole point of the `unknown` case existing.
///
/// Absence of a measurement must not be readable as a negative measurement —
/// the same reason `ScriptExecuteResult.verified` is `nil` rather than `false`
/// when verification did not run (ooxml-swift, `ScriptPipelineExecute`).
enum GlyphCoverage: Equatable, Sendable {
/// The declared family resolved and covers the character.
case hasGlyph(resolvedFont: String)
/// The declared family resolved and does **not** cover the character. A
/// renderer will substitute some other font — for `☑` on macOS, typically
/// a colour emoji face, which is how #189 was noticed.
case noGlyph(resolvedFont: String)
/// Coverage was never measured against the declared font.
case unknown(declaredFont: String, reason: GlyphCoverageUnknownReason)
}

/// Answers **nominal cmap coverage** questions about a *declared* font name —
/// the string that appears in `w:rFonts`, which may name a font this machine
/// does not have.
///
/// Deliberately narrow, and the name of the thing is the honest one: this asks
/// whether a font's character map contains a scalar. It is **not** a prediction
/// of what a renderer will draw. Shaping, normalization, GSUB substitution and
/// variation-selector sequences (`☑︎` / `☑️` are a scalar *pair*) all live above
/// this layer, and a `hasGlyph` verdict does not rule out a renderer choosing a
/// different face for emoji presentation.
///
/// Scope, stated plainly: it measures the local font set. The document is
/// rendered on the reader's machine with the reader's fonts, so every answer is
/// advisory. `unknown` is expected to be common rather than exceptional; on the
/// machine where #189 was diagnosed, all three PMingLiU-family spellings were
/// unresolvable.
///
/// It also takes **one** font name. An OOXML run declares up to four
/// (`w:rFonts` `ascii` / `hAnsi` / `eastAsia` / `cs`) and which one draws a
/// given character depends on that character's script — for the CJK forms in
/// #189, `eastAsia`. Choosing the applicable slot, and resolving style/theme
/// inheritance, is the caller's job and is **not** done here.
enum GlyphCoverageProbe {

/// Whether `declaredFont` names a family present on this machine *and*
/// CoreText resolves it to that same family.
static func fontResolvesLocally(_ declaredFont: String) -> Bool {
if case .resolved = resolve(declaredFont) { return true }
return false
}

/// Coverage of `scalar` in `declaredFont`, or `.unknown` with a reason.
static func coverage(of scalar: Unicode.Scalar, declaredFont: String) -> GlyphCoverage {
let font: CTFont
let family: String
switch resolve(declaredFont) {
case .unresolvable(let reason):
return .unknown(declaredFont: declaredFont, reason: reason)
case .resolved(let f, let name):
font = f
family = name
}

var utf16 = Array(String(scalar).utf16)
var glyphs = [CGGlyph](repeating: 0, count: utf16.count)
// One scalar, so this is one character to CoreText even when it spans a
// surrogate pair: the pair maps to a single glyph at index 0, index 1
// stays zero, and the call still returns true. Measured against Apple
// Color Emoji with U+1F600 — glyphs came back [2096, 0], result true.
// Reading those zeros as "partially unmapped" would report every
// non-BMP character as missing.
let covered = CTFontGetGlyphsForCharacters(font, &utf16, &glyphs, utf16.count)
return covered ? .hasGlyph(resolvedFont: family) : .noGlyph(resolvedFont: family)
}

// MARK: - Resolution

private enum Resolution {
case resolved(CTFont, family: String)
case unresolvable(GlyphCoverageUnknownReason)
}

/// Resolves a declared family name, refusing anything CoreText merely
/// considered a good substitute.
///
/// `CTFontDescriptorCreateMatchingFontDescriptor` with no mandatory
/// attributes is a *best match*, not a lookup — asking for `System Font`
/// hands back the `.SF NS` family. Treating that as success would produce
/// a confident `hasGlyph` / `noGlyph` about a font the document never
/// named, which is exactly the silent wrong answer the `unknown` case
/// exists to prevent.
///
/// Acceptance is by identity rather than by family name alone, because
/// `CTFontCopyFamilyName` answers in English: `標楷體` comes back as
/// `DFKai-SB`, and a family-name comparison would call an installed font
/// missing. Comparing against the resolved font's own name set — family,
/// full, PostScript, and localized family — accepts the localized spelling
/// and the differently-cased one while still rejecting `.SF NS`.
private static func resolve(_ declaredFont: String) -> Resolution {
let trimmed = declaredFont.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .unresolvable(.noDeclaredFont) }

let query = CTFontDescriptorCreateWithAttributes(
[kCTFontFamilyNameAttribute: trimmed] as CFDictionary)
guard let matched = CTFontDescriptorCreateMatchingFontDescriptor(query, nil) else {
return .unresolvable(.notInstalled)
}

let font = CTFontCreateWithFontDescriptor(matched, 0, nil)
let family = CTFontCopyFamilyName(font) as String
guard identities(of: font).contains(where: {
$0.compare(trimmed, options: [.caseInsensitive]) == .orderedSame
}) else {
return .unresolvable(.resolvedToDifferentFamily(family))
}
return .resolved(font, family: family)
}

/// Every name the resolved font answers to, so a localized or differently
/// cased spelling of the declared family is recognised as that family.
private static func identities(of font: CTFont) -> [String] {
var names: [String] = []
for key in [kCTFontFamilyNameKey, kCTFontFullNameKey, kCTFontPostScriptNameKey] {
if let name = CTFontCopyName(font, key) { names.append(name as String) }
}
var language: Unmanaged<CFString>?
if let localized = CTFontCopyLocalizedName(font, kCTFontFamilyNameKey, &language) {
names.append(localized as String)
}
return names
}
}
13 changes: 10 additions & 3 deletions Sources/CheWordMCP/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,14 @@ actor WordMCPServer {

// MARK: - Tools Definition

private var allTools: [Tool] {
/// Internal rather than private so tests can assert on the surface that is
/// actually registered instead of on a copy of the same strings.
///
/// `internal` is module-wide, not test-only — `@testable import` is merely
/// how the tests reach it. Every file in this target can now see it, which
/// is a real if small widening and the reason this note exists rather than
/// the change being silent.
var allTools: [Tool] {
[
// 文件管理
Tool(
Expand Down Expand Up @@ -898,7 +905,7 @@ actor WordMCPServer {
),
Tool(
name: "replace_text",
description: "搜尋並取代文字。v2.1+ cross-run 匹配自動生效;新增 scope / regex / match_case。BREAKING: all 參數已移除(現在恆為 replace-all)。(需先 open_document)",
description: "搜尋並取代文字。v2.1+ cross-run 匹配自動生效;新增 scope / regex / match_case。BREAKING: all 參數已移除(現在恆為 replace-all)。(需先 open_document)\n\n【字元選擇】表單勾選請用 ■(U+25A0) 取代 □(U+25A1),不要用 ☑(U+2611) 或 ☒(U+2612)。實測(本機當前版本)後兩者在 Times New Roman、Arial 與常見 CJK 字型中都沒有字形;缺字形時渲染器會改用別的字型——macOS 上通常是彩色 emoji 字型,其他平台可能是符號字型或 .notdef 方框——勾選框於是與表單其餘部分不一致。本工具不會改動 run 的字型宣告,但實際繪製的字型會變——所以逐格文字比對會全對、外觀卻是錯的。",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
Expand Down Expand Up @@ -932,7 +939,7 @@ actor WordMCPServer {
),
Tool(
name: "replace_text_batch",
description: "批次文字取代(減少 per-call round-trip,單次 save)。Replacements 依陣列順序套用(sequential),後者看到前者結果。per-item scope / regex / match_case 設定。dry_run 略過 disk save(但 in-memory doc 仍被 mutate;需 open_document 還原)。(需先 open_document)",
description: "批次文字取代(減少 per-call round-trip,單次 save)。Replacements 依陣列順序套用(sequential),後者看到前者結果。per-item scope / regex / match_case 設定。dry_run 略過 disk save(但 in-memory doc 仍被 mutate;需 open_document 還原)。(需先 open_document)\n\n【字元選擇】同 replace_text:表單勾選用 ■(U+25A0),勿用 ☑(U+2611) 或 ☒(U+2612)——實測(本機當前版本)在 Times New Roman、Arial 及常見 CJK 字型皆無字形,缺字形時會改用別的字型而與表單不一致。",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
Expand Down
Loading