diff --git a/Documentation/Validation.md b/Documentation/Validation.md index fdcf17a..3e9fa6a 100644 --- a/Documentation/Validation.md +++ b/Documentation/Validation.md @@ -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: `` 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. diff --git a/README.md b/README.md index 73aa11a..9ce0191 100644 --- a/README.md +++ b/README.md @@ -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 后使用。
更多文档 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 7a417d1..4014ae8 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,8 @@ +# 1.6.2 · 考研答题卡式答题页 + +- 考研六个题型的答题页仿照答题卡 2:左上角标明部分与节(如“第四部分:写作 A 节”),右上角是“英语(一)/(二)答题卡 2”。答题区是黑色矩形边框,配深色细横线,题号印在首行左侧(英一翻译 46–50,英一写作 51 / 52,英二翻译 46,英二写作 47 / 48),四角有定位方块,下方注明“超出黑色矩形边框限定区域的答案无效”。六级、雅思保持原样。 +- 光标换成墨色细线,高度与文字一致,不会穿过横线;选中文字时隐藏,继续闪烁。 + # 1.6.1 · 划线句与更好用的答题区 - 题目支持原卷式下划线:英一翻译的 (46)–(50) 在原文中直接以下划线标出,不再另列。评审只翻译和评阅划线句,其余文字作为语境。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index a4335d5..5aaa51d 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -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; @@ -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)"; @@ -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; @@ -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; diff --git a/WriteBench/Components/PlainTextEditor.swift b/WriteBench/Components/PlainTextEditor.swift index f89f622..19a241c 100644 --- a/WriteBench/Components/PlainTextEditor.swift +++ b/WriteBench/Components/PlainTextEditor.swift @@ -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() @@ -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] @@ -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) @@ -139,6 +224,7 @@ struct PlainTextEditor: NSViewRepresentable { } override func didChangeText() { super.didChangeText() + updateCaret() needsDisplay = true } override func draw(_ dirtyRect: NSRect) { @@ -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) } diff --git a/WriteBench/Features/Writing/ImmersiveWritingView.swift b/WriteBench/Features/Writing/ImmersiveWritingView.swift index b91a37c..dba1c84 100644 --- a/WriteBench/Features/Writing/ImmersiveWritingView.swift +++ b/WriteBench/Features/Writing/ImmersiveWritingView.swift @@ -69,17 +69,29 @@ 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") @@ -87,6 +99,21 @@ struct ImmersiveWritingView: View { }.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 { diff --git a/WriteBench/Models/Exam.swift b/WriteBench/Models/Exam.swift index 2b39db1..bd75280 100644 --- a/WriteBench/Models/Exam.swift +++ b/WriteBench/Models/Exam.swift @@ -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)" } diff --git a/WriteBenchTests/PracticeLoopTests.swift b/WriteBenchTests/PracticeLoopTests.swift index 1e29a63..9a12449 100644 --- a/WriteBenchTests/PracticeLoopTests.swift +++ b/WriteBenchTests/PracticeLoopTests.swift @@ -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) +} diff --git a/project.yml b/project.yml index a5f9cfe..7cef971 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.1" - CURRENT_PROJECT_VERSION: "10" + MARKETING_VERSION: "1.6.2" + CURRENT_PROJECT_VERSION: "11" targets: WriteBench: type: application