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
5 changes: 5 additions & 0 deletions Documentation/Validation.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# 1.6.2 validation · 2026-10-02

- 66 Swift tests pass. New coverage: the custom caret replaces the system indicator, stays within one text line on Latin and blank lines, moves with the selection and hides for a range selection; answer-sheet numbering for the six kaoyan tasks.
- The full answering page was rendered offscreen from the app sources for English II small essay and English I translation and compared with a published 英语(二)答题卡 2 writing page (section heading, black frame, dark rules, question number, corner marks).

# 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.
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 自动化测试覆盖 65 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
macOS 自动化测试覆盖 66 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。

<details>
<summary><strong>更多文档</strong></summary>
Expand Down
5 changes: 5 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# 1.6.2 · 考研答题卡式答题页

- 考研六个题型的答题页仿照答题卡 2:左上角标明部分与节(如“第四部分:写作 A 节”),右上角是“英语(一)/(二)答题卡 2”。答题区是黑色矩形边框,配深色细横线,题号印在首行左侧(英一翻译 46–50,英一写作 51 / 52,英二翻译 46,英二写作 47 / 48),四角有定位方块,下方注明“超出黑色矩形边框限定区域的答案无效”。六级、雅思保持原样。
- 光标换成墨色细线,高度与文字一致,不会穿过横线;选中文字时隐藏,继续闪烁。

# 1.6.1 · 划线句与更好用的答题区

- 题目支持原卷式下划线:英一翻译的 (46)–(50) 在原文中直接以下划线标出,不再另列。评审只翻译和评阅划线句,其余文字作为语境。
Expand Down
8 changes: 4 additions & 4 deletions WriteBench.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_NS_ASSERTIONS = NO;
Expand All @@ -674,7 +674,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.6.1;
MARKETING_VERSION = 1.6.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -755,7 +755,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand All @@ -776,7 +776,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.6.1;
MARKETING_VERSION = 1.6.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
Expand Down
108 changes: 103 additions & 5 deletions WriteBench/Components/PlainTextEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@ struct PlainTextEditor: NSViewRepresentable {
var editable = true
var identifier = "essayEditor"
var ruled = false
/// Answer-sheet look: dark printed rules and the question number in the left margin.
var sheetNumber: String? = nil
var requestFocus = false

struct Style: Equatable {
var font: EditorFont
var size: CGFloat
var ruled: Bool
var sheetNumber: String? = nil
/// 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) }
private var style: Style { Style(font: fontStyle, size: fontSize, ruled: ruled, sheetNumber: sheetNumber) }

func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
Expand All @@ -50,7 +53,6 @@ struct PlainTextEditor: NSViewRepresentable {
view.isAutomaticSpellingCorrectionEnabled = false
view.isContinuousSpellCheckingEnabled = false
view.drawsBackground = false
view.insertionPointColor = NSColor(WB.blue)
view.isVerticallyResizable = true
view.isHorizontallyResizable = false
view.autoresizingMask = [.width]
Expand Down Expand Up @@ -110,11 +112,94 @@ struct PlainTextEditor: NSViewRepresentable {
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)
textContainerInset = style.sheetNumber != nil ? NSSize(width: 46, height: 18) : style.ruled ? NSSize(width: 28, height: 18) : NSSize(width: 14, height: 14)
baseline = Self.measureBaseline(attributes, width: 400)
// The system indicator is thick, accent-coloured and as tall as the line; a quiet ink caret replaces it.
insertionPointColor = .clear
needsDisplay = true
updateCaret()
}

// MARK: Caret

private let caret: NSView = {
let view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = NSColor(WB.ink).withAlphaComponent(0.85).cgColor
view.layer?.cornerRadius = 0.75
view.isHidden = true
return view
}()
private var blink: Timer?

/// Where the caret goes: at the insertion point, spanning the text's ascender to just under its baseline.
func caretFrame() -> NSRect? {
guard let manager = layoutManager, let container = textContainer, selectedRange().length == 0 else { return nil }
manager.ensureLayout(for: container)
let text = string as NSString, index = selectedRange().location
let lineRect: NSRect, x: CGFloat
if text.length == 0 || (index >= text.length && text.character(at: text.length - 1) == 10) {
lineRect = manager.extraLineFragmentRect
x = lineRect.minX + container.lineFragmentPadding
} else if index >= text.length {
let glyph = manager.glyphIndexForCharacter(at: text.length - 1)
lineRect = manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
x = manager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container).maxX
} else {
let glyph = manager.glyphIndexForCharacter(at: index)
lineRect = manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil)
x = lineRect.minX + manager.location(forGlyphAt: glyph).x
}
let font = style.font.font(size: style.size)
let top = lineRect.minY + baseline - ceil(font.ascender * 0.92)
let height = ceil(font.ascender * 0.92) + ceil(abs(font.descender) * 0.75)
return NSRect(x: textContainerOrigin.x + x - 0.75, y: textContainerOrigin.y + top, width: 1.5, height: height)
}
func updateCaret(restartBlink: Bool = true) {
if caret.superview == nil { addSubview(caret) }
guard window?.firstResponder === self, window?.isKeyWindow == true, isEditable, let frame = caretFrame() else {
caret.isHidden = true; blink?.invalidate(); blink = nil; return
}
caret.frame = frame
caret.isHidden = false
guard restartBlink else { return }
blink?.invalidate()
blink = Timer.scheduledTimer(withTimeInterval: 0.53, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.caret.isHidden.toggle() }
}
}
override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting: Bool) {
super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelecting)
updateCaret()
}
override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange)
updateCaret()
}
override func becomeFirstResponder() -> Bool {
let accepted = super.becomeFirstResponder()
DispatchQueue.main.async { [weak self] in self?.updateCaret() }
return accepted
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned { caret.isHidden = true; blink?.invalidate(); blink = nil }
return resigned
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
updateCaret(restartBlink: false)
}
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
NotificationCenter.default.removeObserver(self)
guard let newWindow else { blink?.invalidate(); blink = nil; return }
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(windowKeyChanged), name: name, object: newWindow)
}
}
@objc private func windowKeyChanged() { updateCaret() }

/// 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)
Expand All @@ -139,6 +224,7 @@ struct PlainTextEditor: NSViewRepresentable {
}
override func didChangeText() {
super.didChangeText()
updateCaret()
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
Expand All @@ -156,9 +242,21 @@ struct PlainTextEditor: NSViewRepresentable {
path.line(to: NSPoint(x: right, y: aligned))
y += pitch
}
NSColor(red: 0.84, green: 0.87, blue: 0.93, alpha: 1).setStroke()
path.lineWidth = 1
if style.sheetNumber != nil {
// Printed answer sheets use dark, fine rules.
NSColor(white: 0.2, alpha: 0.72).setStroke()
path.lineWidth = 0.75
} else {
NSColor(red: 0.84, green: 0.87, blue: 0.93, alpha: 1).setStroke()
path.lineWidth = 1
}
path.stroke()
if let number = style.sheetNumber {
let labelFont = NSFont.systemFont(ofSize: 11)
let label = NSAttributedString(string: number, attributes: [.font: labelFont, .foregroundColor: NSColor(white: 0.15, alpha: 1)])
// Same baseline as the first line of writing, in the left margin like the printed sheet.
label.draw(at: NSPoint(x: left - label.size().width - 8, y: textContainerOrigin.y + baseline - labelFont.ascender))
}
}
super.draw(dirtyRect)
}
Expand Down
37 changes: 32 additions & 5 deletions WriteBench/Features/Writing/ImmersiveWritingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,51 @@ struct ImmersiveWritingView: View {
}.padding(.leading, 8).padding(.trailing, 28)
}
private var answer: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(store.task.exam == .ielts ? "Answer" : "答题区").font(.system(size: 12, weight: .medium)).foregroundStyle(WB.secondary)
let sheet = store.task.answerSheet
return VStack(alignment: .leading, spacing: sheet == nil ? 16 : 8) {
HStack(alignment: .firstTextBaseline) {
if let sheet {
Text(sheet.section).font(.system(size: 12, weight: .medium)).foregroundStyle(WB.ink)
} else {
Text(store.task.exam == .ielts ? "Answer" : "答题区").font(.system(size: 12, weight: .medium)).foregroundStyle(WB.secondary)
}
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") }
if let sheet { Text(sheet.sheet).font(.system(size: 11)).foregroundStyle(AnswerSheet.magenta) }
}
PlainTextEditor(text: $store.essay, fontSize: editorFontSize, fontStyle: editorFont, editable: true, identifier: "essayEditor",
ruled: editorRuled && store.task.exam != .ielts, requestFocus: true)
ruled: sheet != nil || (editorRuled && store.task.exam != .ielts), sheetNumber: sheet?.number, requestFocus: true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(Rectangle().stroke(Color.black.opacity(0.12), lineWidth: 0.75))
.overlay(Rectangle().stroke(sheet == nil ? Color.black.opacity(0.12) : Color.black.opacity(0.85), lineWidth: sheet == nil ? 0.75 : 1.2))
.padding(sheet == nil ? 0 : 14)
.overlay { if sheet != nil { AnswerSheet.CornerMarks() } }
HStack {
if sheet != nil {
Text("请在答题区域内作答,超出黑色矩形边框限定区域的答案无效").font(.system(size: 10)).foregroundStyle(AnswerSheet.magenta.opacity(0.85))
Text("·").foregroundStyle(WB.secondary.opacity(0.5))
}
Text(store.saveStatus).font(.system(size: 10)).foregroundStyle(WB.secondary.opacity(0.7))
Spacer()
Button("导入手写稿", action: onImport).buttonStyle(.plain).font(.system(size: 11)).foregroundStyle(WB.secondary).disabled(store.isGrading || isRecognizing).accessibilityIdentifier("importHandwritten")
}
}.padding(.leading, 28).padding(.trailing, 8)
}
}
/// Visual cues from the printed 考研 answer sheet.
enum AnswerSheet {
static let magenta = Color(red: 0.86, green: 0.27, blue: 0.52)
/// Solid registration squares at the sheet's corners.
struct CornerMarks: View {
var body: some View {
VStack {
HStack { mark; Spacer(); mark }
Spacer()
HStack { mark; Spacer(); mark }
}.allowsHitTesting(false).accessibilityHidden(true)
}
private var mark: some View { Rectangle().fill(Color.black.opacity(0.85)).frame(width: 8, height: 8) }
}
}
private struct ExamSubmitStyle: ButtonStyle {
@Environment(\.isEnabled) private var enabled
func makeBody(configuration: Configuration) -> some View {
Expand Down
12 changes: 12 additions & 0 deletions WriteBench/Models/Exam.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ enum WritingTask: String, CaseIterable, Identifiable, Codable, Sendable {
var title: String {
switch self { case .kaoyanSmall: "英一小作文"; case .kaoyanLarge: "英一大作文"; case .kaoyan2Small: "英二小作文"; case .kaoyan2Large: "英二大作文"; case .cet6Writing: "Writing"; case .ieltsTask1: "Task 1"; case .ieltsTask2: "Task 2"; case .kaoyanTranslation: "英一翻译"; case .kaoyan2Translation: "英二翻译"; case .cet6Translation: "翻译" }
}
/// How the 考研 answer sheet 2 labels this task: section heading, printed question number and sheet title.
var answerSheet: (section: String, number: String, sheet: String)? {
switch self {
case .kaoyanTranslation: ("第二部分:阅读理解 C 节", "46–50", "英语(一)答题卡 2")
case .kaoyanSmall: ("第三部分:写作 A 节", "51.", "英语(一)答题卡 2")
case .kaoyanLarge: ("第三部分:写作 B 节", "52.", "英语(一)答题卡 2")
case .kaoyan2Translation: ("第三部分:英译汉", "46.", "英语(二)答题卡 2")
case .kaoyan2Small: ("第四部分:写作 A 节", "47.", "英语(二)答题卡 2")
case .kaoyan2Large: ("第四部分:写作 B 节", "48.", "英语(二)答题卡 2")
default: nil
}
}
var isTranslation: Bool { self == .kaoyanTranslation || self == .kaoyan2Translation || self == .cet6Translation }
var targetLanguage: String { (self == .kaoyanTranslation || self == .kaoyan2Translation) ? "Simplified Chinese" : "English" }
var fullTitle: String { "\(exam.title) · \(title)" }
Expand Down
18 changes: 18 additions & 0 deletions WriteBenchTests/PracticeLoopTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,21 @@ private actor RecordingGrader: EssayGradingService {
let steps = zip(tops.dropFirst(), tops).map { $0 - $1 }
#expect(!steps.isEmpty && steps.allSatisfy { abs($0 - 36) < 0.5 })
}
@Test @MainActor func customCaretSitsOnTheTextLineWithoutCrossingTheRule() throws {
let view = RuledTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 400))
view.layoutManager?.usesFontLeading = false
view.string = "Dear Alex,\n\nI am writing"
view.apply(.init(font: .sans, size: 18, ruled: true, sheetNumber: "47."))
#expect(view.insertionPointColor == .clear)
var frames: [NSRect] = []
for index in [0, 4, 12, (view.string as NSString).length] {
view.setSelectedRange(NSRange(location: index, length: 0))
frames.append(try #require(view.caretFrame()))
}
#expect(frames.allSatisfy { $0.height < 36 && $0.height > 15 && $0.width <= 2 })
#expect(frames[1].minX > frames[0].minX && abs(frames[1].minY - frames[0].minY) < 0.5)
#expect(abs(frames[2].minY - frames[0].minY - 72) < 0.5)
view.setSelectedRange(NSRange(location: 0, length: 4))
#expect(view.caretFrame() == nil)
#expect(WritingTask.kaoyan2Small.answerSheet?.number == "47." && WritingTask.kaoyanTranslation.answerSheet?.number == "46–50" && WritingTask.cet6Writing.answerSheet == nil)
}
4 changes: 2 additions & 2 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ settings:
CODE_SIGN_IDENTITY: "-"
ENABLE_HARDENED_RUNTIME: YES
GENERATE_INFOPLIST_FILE: YES
MARKETING_VERSION: "1.6.1"
CURRENT_PROJECT_VERSION: "10"
MARKETING_VERSION: "1.6.2"
CURRENT_PROJECT_VERSION: "11"
targets:
WriteBench:
type: application
Expand Down
Loading