Skip to content
Merged
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
6 changes: 6 additions & 0 deletions Documentation/Validation.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# 1.6.1 validation · 2026-10-02

- 65 Swift tests pass. New coverage: `<u>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 后使用。

<details>
<summary><strong>更多文档</strong></summary>
Expand Down
6 changes: 6 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -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 分钟)和原创练习题。考研题型标签改为“英一小作文 / 英一大作文 / 英二小作文 / 英二大作文 / 英一翻译 / 英二翻译”。
Expand Down
12 changes: 8 additions & 4 deletions WriteBench.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -149,6 +150,7 @@
D7E5B6AA16B1E69446387608 /* RevisionViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevisionViews.swift; sourceTree = "<group>"; };
DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = "<group>"; };
DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingView.swift; sourceTree = "<group>"; };
DD0DD31CC0B614898B36ED76 /* QuestionText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionText.swift; sourceTree = "<group>"; };
E6B358A2E1043646F5A65FA4 /* UsageCost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCost.swift; sourceTree = "<group>"; };
E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WriteBench.entitlements; sourceTree = "<group>"; };
EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionTextReader.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -395,6 +397,7 @@
9AA993471F8CFF92BD9B2956 /* BrandArt.swift */,
D78510A02F0F827272F84695 /* ExamTaskBadge.swift */,
CD6C23EF9D33F68C7816C642 /* PlainTextEditor.swift */,
DD0DD31CC0B614898B36ED76 /* QuestionText.swift */,
51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */,
);
path = Components;
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand All @@ -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)";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
117 changes: 89 additions & 28 deletions WriteBench/Components/PlainTextEditor.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
40 changes: 40 additions & 0 deletions WriteBench/Components/QuestionText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import SwiftUI

/// Question text may mark exam underlines as `<u>…</u>`, 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 = "<u>", close = "</u>"

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[..<start.lowerBound])
let after = rest[start.upperBound...]
guard let end = after.range(of: close) else { result += styled(after); return result }
var underlined = styled(after[..<end.lowerBound])
underlined.underlineStyle = Text.LineStyle(pattern: .solid, color: WB.ink.opacity(0.75))
result += underlined
rest = after[end.upperBound...]
}
return result + styled(rest)
}

/// Section headings such as 【配图说明】 read as headings rather than body text.
private static func styled(_ text: Substring) -> AttributedString {
var output = AttributedString()
var rest = text
while let start = rest.range(of: "【"), let end = rest[start.upperBound...].range(of: "】") {
output += AttributedString(String(rest[..<start.lowerBound]))
var heading = AttributedString(String(rest[start.lowerBound..<end.upperBound]))
heading.inlinePresentationIntent = .stronglyEmphasized
output += heading
rest = rest[end.upperBound...]
}
return output + AttributedString(String(rest))
}
}
2 changes: 1 addition & 1 deletion WriteBench/Features/History/EssayHistoryGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import CryptoKit
let versions: [EssaySession]
var first: EssaySession { versions[0] }
var latest: EssaySession { versions[versions.count - 1] }
var questionTitle: String { first.question.split(whereSeparator: \.isNewline).joined(separator: " ") }
var questionTitle: String { QuestionText.plain(first.question).split(whereSeparator: \.isNewline).joined(separator: " ") }

private init(key: Key, versions: [EssaySession]) {
id = key
Expand Down
2 changes: 1 addition & 1 deletion WriteBench/Features/Review/BackgroundGradingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ struct GradingProgressView: View {
}
if let detail = job.detail { Label(detail, systemImage: "exclamationmark.circle").font(.system(size: 12)).foregroundStyle(WB.amber).textSelection(.enabled) }
DisclosureGroup("本次提交的题目与作答") {
VStack(alignment: .leading, spacing: 16) { Text(job.submission.input.question).foregroundStyle(WB.secondary); Text(job.submission.input.essay) }
VStack(alignment: .leading, spacing: 16) { Text(QuestionText.attributed(job.submission.input.question)).foregroundStyle(WB.secondary); Text(job.submission.input.essay) }
.font(.system(size: 13)).lineSpacing(5).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 12)
}.font(.system(size: 12))
}.padding(.horizontal, 24).padding(.bottom, 24)
Expand Down
Loading
Loading