From 82e925c659cfa00372069fe72a493da4f8731cc1 Mon Sep 17 00:00:00 2001
From: Functionhx <172989722+Functionhx@users.noreply.github.com>
Date: Fri, 2 Oct 2026 20:49:17 -0700
Subject: [PATCH] =?UTF-8?q?macOS=201.6.1=EF=BC=9A=E5=88=92=E7=BA=BF?=
=?UTF-8?q?=E5=8F=A5=E4=B8=8E=E6=9B=B4=E5=A5=BD=E7=94=A8=E7=9A=84=E7=AD=94?=
=?UTF-8?q?=E9=A2=98=E5=8C=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 题目支持 … 下划线标记,英一翻译划线句在原文中直接标出
- 答题区横线按实际排版逐行绘制,光标与文字等高,可选字体、字号与横线并实时预览
- 输入法组字期间不再被草稿同步覆盖
Co-Authored-By: Claude Opus 5.5
---
Documentation/Validation.md | 6 +
README.md | 2 +-
RELEASE-NOTES.md | 6 +
WriteBench.xcodeproj/project.pbxproj | 12 +-
WriteBench/Components/PlainTextEditor.swift | 117 +++++++++++++-----
WriteBench/Components/QuestionText.swift | 40 ++++++
.../Features/History/EssayHistoryGroup.swift | 2 +-
.../Review/BackgroundGradingView.swift | 2 +-
WriteBench/Features/Review/ReviewView.swift | 4 +-
.../Features/Settings/SettingsView.swift | 14 +++
.../Writing/ImmersiveWritingView.swift | 8 +-
.../Writing/QuestionLibraryView.swift | 2 +-
WriteBench/Features/Writing/WritingView.swift | 2 +-
.../Services/DeepSeek/DeepSeekClient.swift | 2 +-
.../Services/Grading/GradingService.swift | 2 +-
WriteBenchTests/PracticeLoopTests.swift | 26 ++++
project.yml | 4 +-
17 files changed, 206 insertions(+), 45 deletions(-)
create mode 100644 WriteBench/Components/QuestionText.swift
diff --git a/Documentation/Validation.md b/Documentation/Validation.md
index a0d426d..fdcf17a 100644
--- a/Documentation/Validation.md
+++ b/Documentation/Validation.md
@@ -1,3 +1,9 @@
+# 1.6.1 validation · 2026-10-02
+
+- 65 Swift tests pass. New coverage: `` markup renders as an underline in place and strips to plain text; a malformed tag degrades to plain text; the ruled editor keeps natural line height with spacing after each line and an even 36 pt pitch at 18 pt for Latin, CJK and blank lines.
+- The ruled editor was rendered offscreen in sans and serif with mixed English/Chinese text: every line sits on its own rule. In an isolated QA copy the English I translation shows (46)–(50) underlined inside the passage, and Settings shows a live editor preview.
+- The user's local English I translation questions were rewritten to the underline format by question id (17 questions, 1 matching draft); no graded essay was changed.
+
# 1.6.0 validation · 2026-10-02
- 63 Swift tests pass on Xcode 27.0. New coverage: English II small/large tasks (scales 10/15, 100/150 words, own rubrics loaded from the bundle), kaoyan task order, and the grader prompt carrying the 【配图说明】 transcription rule with the 0–15 scale.
diff --git a/README.md b/README.md
index b00a879..73aa11a 100644
--- a/README.md
+++ b/README.md
@@ -137,7 +137,7 @@ platforms/android/ Android Studio 项目与手机界面
scripts/ 构建、图标生成与显式联调脚本
```
-macOS 自动化测试覆盖 63 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
+macOS 自动化测试覆盖 65 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
更多文档
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 9f75408..7a417d1 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -1,3 +1,9 @@
+# 1.6.1 · 划线句与更好用的答题区
+
+- 题目支持原卷式下划线:英一翻译的 (46)–(50) 在原文中直接以下划线标出,不再另列。评审只翻译和评阅划线句,其余文字作为语境。
+- 答题区重做:横线按实际排版逐行绘制,每行文字都稳稳落在自己的横线上;光标高度与文字一致;默认改用 SF / 苹方,可在 Settings 切换衬线(New York / 宋体)、调整字号 15–24 pt、开关考研和六级的横线,并实时预览。
+- 输入法组字时不会再被草稿同步打断。评审提示词升级到 1.3.2。
+
# 1.6.0 · 考研英语二写作与图表题
- 新增 **英二小作文**(10 分,约 100 词)和 **英二大作文**(15 分,约 150 词,以图表为主),各有独立评分标准、考试限时(15 / 30 分钟)和原创练习题。考研题型标签改为“英一小作文 / 英一大作文 / 英二小作文 / 英二大作文 / 英一翻译 / 英二翻译”。
diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj
index 64fdd55..a4335d5 100644
--- a/WriteBench.xcodeproj/project.pbxproj
+++ b/WriteBench.xcodeproj/project.pbxproj
@@ -35,6 +35,7 @@
57B1584FB0F0DC411801C7D5 /* WritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3788E5EB782131E544DDEDC /* WritingView.swift */; };
611D1CBBEE2017C06DCD6D8D /* GradingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634F608AF0DF72D3DE51E438 /* GradingService.swift */; };
6C1F7BEC5994E87DFA1CEF27 /* DeepSeekClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A399A503863E6199B340A50C /* DeepSeekClient.swift */; };
+ 6C3E278AD86905C7C0A63BCE /* QuestionText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0DD31CC0B614898B36ED76 /* QuestionText.swift */; };
76165A1D695B6E1E39EE5906 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A5D3F3824C11B7B262C308 /* HistoryView.swift */; };
7A7FFA1F4A7D393FDC0F3AA0 /* EssayHistoryGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */; };
7ADD51F051853BB04DC26A4D /* WindowImmersionBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */; };
@@ -149,6 +150,7 @@
D7E5B6AA16B1E69446387608 /* RevisionViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevisionViews.swift; sourceTree = ""; };
DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = ""; };
DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingView.swift; sourceTree = ""; };
+ DD0DD31CC0B614898B36ED76 /* QuestionText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionText.swift; sourceTree = ""; };
E6B358A2E1043646F5A65FA4 /* UsageCost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCost.swift; sourceTree = ""; };
E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WriteBench.entitlements; sourceTree = ""; };
EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionTextReader.swift; sourceTree = ""; };
@@ -395,6 +397,7 @@
9AA993471F8CFF92BD9B2956 /* BrandArt.swift */,
D78510A02F0F827272F84695 /* ExamTaskBadge.swift */,
CD6C23EF9D33F68C7816C642 /* PlainTextEditor.swift */,
+ DD0DD31CC0B614898B36ED76 /* QuestionText.swift */,
51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */,
);
path = Components;
@@ -558,6 +561,7 @@
A97D07FFDB81393349741F89 /* PracticeView.swift in Sources */,
291B90A1A7DDA40A4591BE8A /* QuestionBank.swift in Sources */,
3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */,
+ 6C3E278AD86905C7C0A63BCE /* QuestionText.swift in Sources */,
17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */,
042D9A8FAAC22D71BDF6F7E4 /* ReviewCard.swift in Sources */,
DB3DEEA68D82D9F8BECF108E /* ReviewOutline.swift in Sources */,
@@ -655,7 +659,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 9;
+ CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_NS_ASSERTIONS = NO;
@@ -670,7 +674,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
- MARKETING_VERSION = 1.6.0;
+ MARKETING_VERSION = 1.6.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -751,7 +755,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 9;
+ CURRENT_PROJECT_VERSION = 10;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -772,7 +776,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
- MARKETING_VERSION = 1.6.0;
+ MARKETING_VERSION = 1.6.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
diff --git a/WriteBench/Components/PlainTextEditor.swift b/WriteBench/Components/PlainTextEditor.swift
index c125531..f89f622 100644
--- a/WriteBench/Components/PlainTextEditor.swift
+++ b/WriteBench/Components/PlainTextEditor.swift
@@ -1,21 +1,44 @@
import SwiftUI
import AppKit
+enum EditorFont: String, CaseIterable, Identifiable {
+ case sans, serif
+ var id: String { rawValue }
+ var title: String { self == .sans ? "无衬线 · SF / 苹方" : "衬线 · New York / 宋体" }
+ func font(size: CGFloat) -> NSFont {
+ let system = NSFont.systemFont(ofSize: size)
+ guard self == .serif, let serif = system.fontDescriptor.withDesign(.serif) else { return system }
+ // Chinese falls back to Songti so both scripts read as one serif page.
+ let descriptor = serif.addingAttributes([.cascadeList: [NSFontDescriptor(name: "Songti SC", size: size)]])
+ return NSFont(descriptor: descriptor, size: size) ?? system
+ }
+}
+
/// A plain-text Mac editor with native selection, find, spelling, and undo/redo.
struct PlainTextEditor: NSViewRepresentable {
@Binding var text: String
var fontSize: CGFloat = 16
+ var fontStyle: EditorFont = .sans
var editable = true
var identifier = "essayEditor"
var ruled = false
var requestFocus = false
+
+ struct Style: Equatable {
+ var font: EditorFont
+ var size: CGFloat
+ var ruled: Bool
+ /// Ruled paper uses one fixed line pitch so every line sits on its own rule.
+ var lineHeight: CGFloat { ruled ? (size * 2).rounded() : (size * 1.55).rounded() }
+ }
+ private var style: Style { Style(font: fontStyle, size: fontSize, ruled: ruled) }
+
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
let view = RuledTextView()
- view.showsRuling = ruled
view.focusOnAttachment = requestFocus
view.isRichText = false
view.isEditable = editable
@@ -27,50 +50,83 @@ struct PlainTextEditor: NSViewRepresentable {
view.isAutomaticSpellingCorrectionEnabled = false
view.isContinuousSpellCheckingEnabled = false
view.drawsBackground = false
- view.font = ruled ? (NSFont(name: "TimesNewRomanPSMT", size: fontSize) ?? .systemFont(ofSize: fontSize)) : .systemFont(ofSize: fontSize)
- view.textColor = NSColor(WB.ink)
view.insertionPointColor = NSColor(WB.blue)
- view.textContainerInset = ruled ? NSSize(width: 24, height: 20) : NSSize(width: 12, height: 14)
view.isVerticallyResizable = true
view.isHorizontallyResizable = false
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
- let style = NSMutableParagraphStyle()
- style.lineSpacing = ruled ? 0 : 6
- if ruled { style.minimumLineHeight = 34; style.maximumLineHeight = 34; view.layoutManager?.usesFontLeading = false }
- view.defaultParagraphStyle = style
- view.typingAttributes = [.font: view.font ?? NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor(WB.ink), .paragraphStyle: style]
+ view.layoutManager?.usesFontLeading = false
view.delegate = context.coordinator
view.setAccessibilityIdentifier(identifier)
let labels = ["essayEditor": "我的作文", "questionEditor": "题目文字", "textQuestionImportEditor": "导入题目文字", "rewriteEditor": "重写作文", "ocrEssayEditor": "识别文字校对", "ocrQuestionEditor": "识别题目校对"]
view.setAccessibilityLabel(labels[identifier] ?? "Writing editor")
+ view.string = text
+ view.apply(style)
+ context.coordinator.style = style
scroll.documentView = view
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
context.coordinator.parent = self
- guard let view = scroll.documentView as? NSTextView else { return }
- if view.string != text { view.string = text; view.undoManager?.removeAllActions() }
+ guard let view = scroll.documentView as? RuledTextView else { return }
+ if view.string != text, !view.hasMarkedText() { view.string = text; view.undoManager?.removeAllActions(); view.apply(style) }
+ if context.coordinator.style != style { context.coordinator.style = style; view.apply(style) }
view.isEditable = editable
- view.needsDisplay = true
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
@MainActor final class Coordinator: NSObject, NSTextViewDelegate {
var parent: PlainTextEditor
+ var style: Style?
init(_ parent: PlainTextEditor) { self.parent = parent }
func textDidChange(_ notification: Notification) {
guard let view = notification.object as? NSTextView else { return }
parent.text = view.string
- view.needsDisplay = true
}
}
}
@MainActor final class RuledTextView: NSTextView {
- var showsRuling = false
var focusOnAttachment = false
private var didFocus = false
+ private var style = PlainTextEditor.Style(font: .sans, size: 16, ruled: false)
+ /// Baseline position inside a line fragment, measured with the real layout machinery.
+ private var baseline: CGFloat = 0
+
+ func apply(_ style: PlainTextEditor.Style) {
+ self.style = style
+ let font = style.font.font(size: style.size)
+ // Lines keep the font's natural height and the rest of the pitch is spacing after the line,
+ // so the insertion point stays as tall as the text instead of filling the whole ruled line.
+ let natural = ceil(font.ascender - font.descender + font.leading)
+ let paragraph = NSMutableParagraphStyle()
+ paragraph.minimumLineHeight = natural
+ paragraph.maximumLineHeight = natural
+ paragraph.lineSpacing = max(0, style.lineHeight - natural)
+ let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: NSColor(WB.ink), .paragraphStyle: paragraph]
+ self.font = font
+ defaultParagraphStyle = paragraph
+ typingAttributes = attributes
+ if let storage = textStorage, storage.length > 0, !hasMarkedText() {
+ storage.setAttributes(attributes, range: NSRange(location: 0, length: storage.length))
+ }
+ textContainerInset = style.ruled ? NSSize(width: 28, height: 18) : NSSize(width: 14, height: 14)
+ baseline = Self.measureBaseline(attributes, width: 400)
+ needsDisplay = true
+ }
+
+ /// Lays out a Latin + CJK sample with the same attributes so empty pages rule exactly like typed ones.
+ private static func measureBaseline(_ attributes: [NSAttributedString.Key: Any], width: CGFloat) -> CGFloat {
+ let storage = NSTextStorage(string: "Hg中,", attributes: attributes)
+ let manager = NSLayoutManager()
+ manager.usesFontLeading = false
+ let container = NSTextContainer(size: NSSize(width: width, height: .greatestFiniteMagnitude))
+ manager.addTextContainer(container)
+ storage.addLayoutManager(manager)
+ manager.ensureLayout(for: container)
+ return manager.location(forGlyphAt: 0).y
+ }
+
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if focusOnAttachment, !didFocus, let window {
@@ -81,23 +137,28 @@ struct PlainTextEditor: NSViewRepresentable {
}
}
}
+ override func didChangeText() {
+ super.didChangeText()
+ needsDisplay = true
+ }
override func draw(_ dirtyRect: NSRect) {
- if showsRuling {
- let lineHeight: CGFloat = 34
- var baseline: CGFloat = 25
- if let layoutManager, let textContainer {
- layoutManager.ensureLayout(for: textContainer)
- if layoutManager.numberOfGlyphs > 0 { baseline = layoutManager.location(forGlyphAt: 0).y }
- }
- let first = textContainerOrigin.y + baseline + 4
+ if style.ruled {
+ let pitch = style.lineHeight
+ let descent = ceil(abs(style.font.font(size: style.size).descender))
+ // One rule per line, just below the descenders of that line.
+ let first = textContainerOrigin.y + baseline + descent + max(3, (pitch - baseline - descent) * 0.3)
+ let left = textContainerInset.width, right = bounds.width - textContainerInset.width
let path = NSBezierPath()
- var y = first + max(0, floor((dirtyRect.minY - first) / lineHeight)) * lineHeight
- while y < dirtyRect.maxY {
- path.move(to: NSPoint(x: textContainerInset.width, y: y))
- path.line(to: NSPoint(x: bounds.width - textContainerInset.width, y: y))
- y += lineHeight
+ var y = first + max(0, floor((dirtyRect.minY - first) / pitch)) * pitch
+ while y < dirtyRect.maxY + pitch {
+ let aligned = floor(y) + 0.5
+ path.move(to: NSPoint(x: left, y: aligned))
+ path.line(to: NSPoint(x: right, y: aligned))
+ y += pitch
}
- NSColor(white: 0.84, alpha: 1).setStroke(); path.lineWidth = 0.5; path.stroke()
+ NSColor(red: 0.84, green: 0.87, blue: 0.93, alpha: 1).setStroke()
+ path.lineWidth = 1
+ path.stroke()
}
super.draw(dirtyRect)
}
diff --git a/WriteBench/Components/QuestionText.swift b/WriteBench/Components/QuestionText.swift
new file mode 100644
index 0000000..7dc13a3
--- /dev/null
+++ b/WriteBench/Components/QuestionText.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+/// Question text may mark exam underlines as `…`, the way a paper underlines the segments to translate.
+/// Graders receive the markup verbatim; people see a real underline.
+enum QuestionText {
+ private static let open = "", close = ""
+
+ static func plain(_ text: String) -> String {
+ text.replacingOccurrences(of: open, with: "").replacingOccurrences(of: close, with: "")
+ }
+
+ static func attributed(_ text: String) -> AttributedString {
+ var result = AttributedString()
+ var rest = Substring(text)
+ while let start = rest.range(of: open) {
+ result += styled(rest[.. AttributedString {
+ var output = AttributedString()
+ var rest = text
+ while let start = rest.range(of: "【"), let end = rest[start.upperBound...].range(of: "】") {
+ output += AttributedString(String(rest[.. Void
@@ -59,7 +62,7 @@ struct ImmersiveWritingView: View {
Text("试题").font(.system(size: 12, weight: .medium)).foregroundStyle(WB.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 24) {
- Text(store.question).font(.system(size: 15)).lineSpacing(7).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
+ Text(QuestionText.attributed(store.question)).font(.system(size: 15)).lineSpacing(7).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
if let data = store.questionImage, let image = NSImage(data: data) { Image(nsImage: image).resizable().scaledToFit().accessibilityLabel("题目原图") }
}
}.scrollIndicators(.hidden)
@@ -72,7 +75,8 @@ struct ImmersiveWritingView: View {
Spacer()
if showLiveWordCount { Text(store.task.targetLanguage == "Simplified Chinese" ? "\(store.essay.count) 字符" : "\(store.words) words").font(.system(size: 12)).monospacedDigit().foregroundStyle(WB.secondary).accessibilityIdentifier("liveWordCount") }
}
- PlainTextEditor(text: $store.essay, fontSize: store.task.exam == .ielts ? 18 : 20, editable: true, identifier: "essayEditor", ruled: store.task.exam != .ielts, requestFocus: true)
+ PlainTextEditor(text: $store.essay, fontSize: editorFontSize, fontStyle: editorFont, editable: true, identifier: "essayEditor",
+ ruled: editorRuled && store.task.exam != .ielts, requestFocus: true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(Rectangle().stroke(Color.black.opacity(0.12), lineWidth: 0.75))
HStack {
diff --git a/WriteBench/Features/Writing/QuestionLibraryView.swift b/WriteBench/Features/Writing/QuestionLibraryView.swift
index a4a57fe..8be374e 100644
--- a/WriteBench/Features/Writing/QuestionLibraryView.swift
+++ b/WriteBench/Features/Writing/QuestionLibraryView.swift
@@ -100,7 +100,7 @@ struct QuestionLibraryView: View {
Text("已练 \(attempts.count) 次 · 最近 \(latest.finalScore.scoreText) / \(Int(item.task.maxScore))").font(.system(size: 11)).foregroundStyle(WB.green)
} else { Text("未练习").font(.system(size: 11)).foregroundStyle(WB.secondary) }
}
- Text(item.prompt).font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(3).frame(maxWidth: .infinity, alignment: .leading)
+ Text(QuestionText.plain(item.prompt)).font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(3).frame(maxWidth: .infinity, alignment: .leading)
}
}
}.buttonStyle(.plain)
diff --git a/WriteBench/Features/Writing/WritingView.swift b/WriteBench/Features/Writing/WritingView.swift
index 7e202f5..7dfa0cd 100644
--- a/WriteBench/Features/Writing/WritingView.swift
+++ b/WriteBench/Features/Writing/WritingView.swift
@@ -107,7 +107,7 @@ struct WritingView: View {
if editingQuestion {
PlainTextEditor(text: $store.question, fontSize: 15, identifier: "questionEditor").frame(height: 230).background(WB.canvas, in: RoundedRectangle(cornerRadius: 8))
} else {
- Text(store.question).font(.system(size: 16)).lineSpacing(7).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
+ Text(QuestionText.attributed(store.question)).font(.system(size: 16)).lineSpacing(7).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
if let data = store.questionImage, let image = NSImage(data: data) {
DisclosureGroup("题目配图", isExpanded: $showQuestionImage) { Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 320).padding(.top, 8) }.font(.system(size: 12)).foregroundStyle(WB.secondary)
diff --git a/WriteBench/Services/DeepSeek/DeepSeekClient.swift b/WriteBench/Services/DeepSeek/DeepSeekClient.swift
index 2e0568d..22b538e 100644
--- a/WriteBench/Services/DeepSeek/DeepSeekClient.swift
+++ b/WriteBench/Services/DeepSeek/DeepSeekClient.swift
@@ -124,7 +124,7 @@ extension GraderPrompt {
Apply this rubric (version \(RubricLoader.version)):
\(input.rubric)
Overall score is on 0–\(input.task.maxScore), in increments of 0.5. Diagnostic taskCompletion, language, coherence, register are 0–10; these diagnostics are not a replacement for the exam rubric. Never conflate the two scales.
- Give concise, specific explanations in Simplified Chinese. Keep corrected text and improvedVersion in \(input.task.targetLanguage). Original spans must be copied verbatim from the student answer. \(input.task.isTranslation ? "Assess translation fidelity against the source, completeness, logical relationships and natural target-language expression. Do not require essay arguments or penalize valid alternative translations. Distinguish omissions, additions and mistranslations. improvedVersion must be a complete faithful translation, not an essay." : "Preserve the student's meaning.") Do not invent prompt facts or data missing from a diagram. If the question contains a section marked 【配图说明】, 【图画内容】 or 【图表数据】, treat it as the authoritative transcription of the picture, chart or table the student saw: check the essay's description and data against it, and penalize misread or invented figures. If essential information is missing, clearly explain the limitation in summary and taskCompletion.
+ Give concise, specific explanations in Simplified Chinese. Keep corrected text and improvedVersion in \(input.task.targetLanguage). Original spans must be copied verbatim from the student answer. \(input.task.isTranslation ? "Assess translation fidelity against the source, completeness, logical relationships and natural target-language expression. Do not require essay arguments or penalize valid alternative translations. Distinguish omissions, additions and mistranslations. improvedVersion must be a complete faithful translation, not an essay." : "Preserve the student's meaning.") Do not invent prompt facts or data missing from a diagram. If the question contains a section marked 【配图说明】, 【图画内容】 or 【图表数据】, treat it as the authoritative transcription of the picture, chart or table the student saw: check the essay's description and data against it, and penalize misread or invented figures. Text wrapped in … in the question is underlined on the exam paper; when the directions ask to translate the underlined segments, translate and assess only those numbered segments and use the rest of the passage as context. If essential information is missing, clearly explain the limitation in summary and taskCompletion.
Return JSON only with exactly this schema; every field is required. Write summary FIRST so it can be previewed while the rest streams. Give a concise verdict in summary; do not expose private reasoning. strengths identifies 1–3 specific things done well, weaknesses identifies 1–3 scoring-relevant problems, and improvements gives 1–3 concrete next-rewrite actions. Do not invent praise; arrays may be empty when there is no supported observation.
{"summary": "specific examiner verdict", "strengths": ["what works, with evidence"], "weaknesses": ["what loses marks, with evidence"], "improvements": ["specific next-rewrite action"],
"score": 0.0, "taskCompletion": 0.0, "language": 0.0, "coherence": 0.0, "register": 0.0,
diff --git a/WriteBench/Services/Grading/GradingService.swift b/WriteBench/Services/Grading/GradingService.swift
index 01ea8f6..da54ba6 100644
--- a/WriteBench/Services/Grading/GradingService.swift
+++ b/WriteBench/Services/Grading/GradingService.swift
@@ -104,4 +104,4 @@ enum RubricLoader {
return try String(contentsOf: url, encoding: .utf8)
}
}
-enum GraderPrompt { static let version = "1.3.1" }
+enum GraderPrompt { static let version = "1.3.2" }
diff --git a/WriteBenchTests/PracticeLoopTests.swift b/WriteBenchTests/PracticeLoopTests.swift
index be74c05..1e29a63 100644
--- a/WriteBenchTests/PracticeLoopTests.swift
+++ b/WriteBenchTests/PracticeLoopTests.swift
@@ -242,3 +242,29 @@ private actor RecordingGrader: EssayGradingService {
let prompt = GraderPrompt.system(judge: .a, input: input)
#expect(prompt.contains("【配图说明】") && prompt.contains("0–15.0"))
}
+
+// MARK: Question underline and editor
+
+@Test func underlineMarkupRendersInPlaceAndStripsForPlainText() {
+ let source = "Read and translate. (46) We don't have to learn it. It is innate. 【配图说明】图"
+ #expect(QuestionText.plain(source) == "Read and translate. (46) We don't have to learn it. It is innate. 【配图说明】图")
+ let attributed = QuestionText.attributed(source)
+ #expect(String(attributed.characters) == QuestionText.plain(source))
+ let underlined = attributed.runs.filter { $0.underlineStyle != nil }.map { String(attributed[$0.range].characters) }
+ #expect(underlined == ["We don't have to learn it."])
+ #expect(String(QuestionText.attributed("No markup open").characters) == "No markup open")
+}
+@Test @MainActor func ruledEditorKeepsTextHeightLinesAndEvenPitch() throws {
+ let view = RuledTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 400))
+ view.layoutManager?.usesFontLeading = false
+ view.string = "First line\n第二行\n\nFourth"
+ view.apply(.init(font: .serif, size: 18, ruled: true))
+ let manager = try #require(view.layoutManager), container = try #require(view.textContainer)
+ manager.ensureLayout(for: container)
+ let style = try #require(view.defaultParagraphStyle)
+ #expect(style.lineSpacing > 0 && style.maximumLineHeight < 36)
+ var tops: [CGFloat] = []
+ manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: manager.numberOfGlyphs)) { rect, _, _, _, _ in tops.append(rect.minY) }
+ let steps = zip(tops.dropFirst(), tops).map { $0 - $1 }
+ #expect(!steps.isEmpty && steps.allSatisfy { abs($0 - 36) < 0.5 })
+}
diff --git a/project.yml b/project.yml
index f7c7a39..a5f9cfe 100644
--- a/project.yml
+++ b/project.yml
@@ -11,8 +11,8 @@ settings:
CODE_SIGN_IDENTITY: "-"
ENABLE_HARDENED_RUNTIME: YES
GENERATE_INFOPLIST_FILE: YES
- MARKETING_VERSION: "1.6.0"
- CURRENT_PROJECT_VERSION: "9"
+ MARKETING_VERSION: "1.6.1"
+ CURRENT_PROJECT_VERSION: "10"
targets:
WriteBench:
type: application