Skip to content

Commit be70a76

Browse files
committed
feat(input): add Roman numeral input support
1 parent 9e41416 commit be70a76

14 files changed

Lines changed: 511 additions & 65 deletions

File tree

.github/workflows/continuous-integration-workflow-xcode-latest.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ jobs:
4545
- name: Test ChineseNumbers
4646
run: swift test --enable-code-coverage
4747
working-directory: Packages/ChineseNumbers
48+
- name: Test RomanNumbers
49+
run: swift test --enable-code-coverage
50+
working-directory: Packages/RomanNumbers
4851
- name: Clean McBopomofo for testing
4952
run: xcodebuild -scheme McBopomofo -configuration Debug clean
5053
- name: Test McBopomofo

McBopomofo.xcodeproj/project.pbxproj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
D47F7DD3278C1263002F9DD7 /* UserOverrideModel.cpp in Sources */ = {isa = PBXBuildFile; fileRef = D47F7DD2278C1263002F9DD7 /* UserOverrideModel.cpp */; };
7474
D485D3B92796A8A000657FF3 /* PreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3B82796A8A000657FF3 /* PreferencesTests.swift */; };
7575
D485D3C02796CE3200657FF3 /* VersionUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */; };
76+
D48F853E2EACDB6C00C2FDAB /* RomanNumbers in Frameworks */ = {isa = PBXBuildFile; productRef = D48F853D2EACDB6C00C2FDAB /* RomanNumbers */; };
7677
D4A13D5A27A59F0B003BE359 /* InputMethodController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A13D5927A59D5C003BE359 /* InputMethodController.swift */; };
7778
D4A8E43627A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A8E43527A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift */; };
7879
D4C2A9872B4309D700113711 /* UTF8HelperTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = D4C2A9862B4309D700113711 /* UTF8HelperTest.mm */; };
@@ -215,6 +216,7 @@
215216
D485D3B62796A8A000657FF3 /* McBopomofoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = McBopomofoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
216217
D485D3B82796A8A000657FF3 /* PreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTests.swift; sourceTree = "<group>"; };
217218
D485D3BF2796CE3200657FF3 /* VersionUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateTests.swift; sourceTree = "<group>"; };
219+
D48F853C2EACCDE900C2FDAB /* RomanNumbers */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = RomanNumbers; path = Packages/RomanNumbers; sourceTree = "<group>"; };
218220
D495583A27A5C6C4006ADE1C /* LanguageModelManager+Privates.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "LanguageModelManager+Privates.h"; sourceTree = "<group>"; };
219221
D4A13D5927A59D5C003BE359 /* InputMethodController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMethodController.swift; sourceTree = "<group>"; };
220222
D4A8E43527A9E982002F7A07 /* KeyHandlerPlainBopomofoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyHandlerPlainBopomofoTests.swift; sourceTree = "<group>"; };
@@ -253,6 +255,7 @@
253255
D427F7A927905E90004A2160 /* TooltipUI in Frameworks */,
254256
D4451AC92E688C6B00E8F5AB /* SystemCharacterInfo in Frameworks */,
255257
D47D73C327A7200500255A50 /* FSEventStreamHelper in Frameworks */,
258+
D48F853E2EACDB6C00C2FDAB /* RomanNumbers in Frameworks */,
256259
D4E7917A2B52CDE500676A68 /* ChineseNumbers in Frameworks */,
257260
D427F76A278C9E29004A2160 /* CandidateUI in Frameworks */,
258261
D4C9CAB127AAC9690058DFEA /* NSStringUtils in Frameworks */,
@@ -459,6 +462,7 @@
459462
D4E5EEE22E68A9B30068BC58 /* SystemCharacterInfo */,
460463
D41B626A2B86EAD400583148 /* BopomofoBraille */,
461464
D4E791782B52CDCF00676A68 /* ChineseNumbers */,
465+
D48F853C2EACCDE900C2FDAB /* RomanNumbers */,
462466
D427F768278C9D0D004A2160 /* CandidateUI */,
463467
D427F7A727905E43004A2160 /* TooltipUI */,
464468
D427F7AC27907B7E004A2160 /* NotifierUI */,
@@ -539,6 +543,7 @@
539543
D4E791792B52CDE500676A68 /* ChineseNumbers */,
540544
D41B626B2B86EAE900583148 /* BopomofoBraille */,
541545
D4451AC82E688C6B00E8F5AB /* SystemCharacterInfo */,
546+
D48F853D2EACDB6C00C2FDAB /* RomanNumbers */,
542547
);
543548
productName = McBopomofo;
544549
productReference = 6A0D4EA215FC0D2D00ABF4B3 /* McBopomofo.app */;
@@ -1467,6 +1472,10 @@
14671472
isa = XCSwiftPackageProductDependency;
14681473
productName = FSEventStreamHelper;
14691474
};
1475+
D48F853D2EACDB6C00C2FDAB /* RomanNumbers */ = {
1476+
isa = XCSwiftPackageProductDependency;
1477+
productName = RomanNumbers;
1478+
};
14701479
D4C9CAB027AAC9690058DFEA /* NSStringUtils */ = {
14711480
isa = XCSwiftPackageProductDependency;
14721481
productName = NSStringUtils;

Packages/RomanNumbers/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// swift-tools-version: 5.9
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "RomanNumbers",
8+
products: [
9+
// Products define the executables and libraries a package produces, making them visible to other packages.
10+
.library(
11+
name: "RomanNumbers",
12+
targets: ["RomanNumbers"]
13+
),
14+
],
15+
targets: [
16+
// Targets are the basic building blocks of a package, defining a module or a test suite.
17+
// Targets can depend on other targets in this package and products from dependencies.
18+
.target(
19+
name: "RomanNumbers"
20+
),
21+
.testTarget(
22+
name: "RomanNumbersTests",
23+
dependencies: ["RomanNumbers"]
24+
),
25+
]
26+
)

Packages/RomanNumbers/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# RomanNumbers
2+
3+
RomanNumbers is a Swift package that converts decimal numbers into Roman
4+
numerals. It powers parts of the McBopomofo input method project where formatted
5+
Roman numerals are needed in Swift and Objective-C contexts.
6+
7+
## Features
8+
9+
- Supports integer values from 0 through 3999
10+
- Offers three output styles via `RomanNumbersStyle`: `alphabets`,
11+
`fullWidthUpper` (Unicode U+2160–U+216F), and `fullWidthLower` (Unicode
12+
U+2170–U+217F)
13+
- Accepts either `Int` input or decimal text strings
14+
- Throws descriptive errors (`RomanNumbersErrors`) when the source value is out of range or invalid
15+
- Marked with `@objc` so the conversion APIs are reachable from Objective-C code
16+
17+
## Installation
18+
19+
Add RomanNumbers to the `dependencies` section of your `Package.swift`:
20+
21+
```swift
22+
.dependencies([
23+
.package(path: "Packages/RomanNumbers")
24+
])
25+
```
26+
27+
Then link the library from a target:
28+
29+
```swift
30+
.target(
31+
name: "YourTarget",
32+
dependencies: [
33+
.product(name: "RomanNumbers", package: "RomanNumbers")
34+
]
35+
)
36+
```
37+
38+
If you vend the package from another repository, replace the `.package` path
39+
with the appropriate `.package(url: "...", from: "...")` declaration.
40+
41+
## Usage
42+
43+
```swift
44+
import RomanNumbers
45+
46+
let number = 2025
47+
let standard = try RomanNumbers.convert(input: number)
48+
let upperFullWidth = try RomanNumbers.convert(input: number, style: .fullWidthUpper)
49+
let lowerFromText = try RomanNumbers.convert(string: "3999", style: .fullWidthLower)
50+
51+
print(standard) // "MMXXV"
52+
print(upperFullWidth) // Unicode Roman numeral letters in the U+2160 block
53+
print(lowerFromText) // Unicode Roman numeral letters in the U+2170 block
54+
```
55+
56+
## Error Handling
57+
58+
`RomanNumbers.convert` throws values of `RomanNumbersErrors`:
59+
60+
- `tooLarge`: the input exceeds 3999
61+
- `tooSmall`: the input is negative
62+
- `invalidInput`: the string argument cannot be parsed as an integer
63+
64+
These errors conform to `LocalizedError`, providing human-readable descriptions
65+
suitable for UI presentation.
66+
67+
## Testing
68+
69+
Run the package tests with:
70+
71+
```bash
72+
swift test
73+
```
74+
75+
The suite exercises every conversion style and covers edge cases for the
76+
supported numeric range.
77+
78+
## License
79+
80+
RomanNumbers ships as part of McBopomofo. Refer to the repository's root
81+
`LICENSE` file for licensing terms.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import Foundation
2+
3+
@objc
4+
public enum RomanNumbersErrors: Int, Error, LocalizedError {
5+
case tooLarge
6+
case tooSmall
7+
case invalidInput
8+
9+
public var errorDescription: String? {
10+
switch self {
11+
case .tooLarge:
12+
"Cannot be larger then 3999"
13+
case .tooSmall:
14+
"Cannot be less then 0"
15+
case .invalidInput:
16+
"Input is not a valid integer"
17+
}
18+
}
19+
}
20+
21+
@objc public enum RomanNumbersStyle: Int {
22+
case alphabets
23+
case fullWidthUpper
24+
case fullWidthLower
25+
}
26+
27+
struct DigitsMap {
28+
let digits: [String]
29+
let tens: [String]
30+
let hundreds: [String]
31+
let thousands: [String]
32+
}
33+
34+
extension RomanNumbersStyle {
35+
var digitsMap: DigitsMap {
36+
switch self {
37+
case .alphabets:
38+
DigitsMap(
39+
digits: ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"],
40+
tens: ["", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"],
41+
hundreds: ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"],
42+
thousands: ["", "M", "MM", "MMM"])
43+
case .fullWidthUpper:
44+
DigitsMap(
45+
digits: ["", "", "", "", "", "", "", "", "", ""],
46+
tens: ["", "", "ⅩⅩ", "ⅩⅩⅩ", "ⅩⅬ", "", "ⅬⅩ", "ⅬⅩⅩ", "ⅬⅩⅩⅩ", "ⅩⅭ"],
47+
hundreds: ["", "", "ⅭⅭ", "ⅭⅭⅭ", "ⅭⅮ", "", "ⅮⅭ", "ⅮⅭⅭ", "ⅮⅭⅭⅭ", "ⅭⅯ"],
48+
thousands: ["", "", "ⅯⅯ", "ⅯⅯⅯ"])
49+
case .fullWidthLower:
50+
DigitsMap(
51+
digits: ["", "", "", "", "", "", "", "", "", ""],
52+
tens: ["", "", "ⅹⅹ", "ⅹⅹⅹ", "ⅹⅼ", "", "ⅼⅹ", "ⅼⅹⅹ", "ⅼⅹⅹⅹ", "ⅹⅽ"],
53+
hundreds: ["", "", "ⅽⅽ", "ⅽⅽⅽ", "ⅽⅾ", "", "ⅾⅽ", "ⅾⅽⅽ", "ⅾⅽⅽⅽ", "ⅽⅿ"],
54+
thousands: ["", "", "ⅿⅿ", "ⅿⅿⅿ"])
55+
}
56+
}
57+
}
58+
59+
@objc
60+
public class RomanNumbers: NSObject {
61+
@objc(convertWithInt:style:error:)
62+
public static func convert(input: Int, style: RomanNumbersStyle = .alphabets) throws -> String {
63+
if input > 3999 {
64+
throw RomanNumbersErrors.tooLarge
65+
}
66+
if input < 0 {
67+
throw RomanNumbersErrors.tooSmall
68+
}
69+
70+
if style == .fullWidthUpper {
71+
switch input {
72+
case 11:
73+
return ""
74+
case 12:
75+
return ""
76+
default:
77+
break
78+
}
79+
}
80+
81+
if style == .fullWidthLower {
82+
switch input {
83+
case 11:
84+
return ""
85+
case 12:
86+
return ""
87+
default:
88+
break
89+
}
90+
91+
}
92+
93+
let thou = input / 1000
94+
let hund = (input % 1000) / 100
95+
let ten = (input % 100) / 10
96+
let digit = input % 10
97+
98+
let map = style.digitsMap
99+
100+
let result = map.thousands[thou] + map.hundreds[hund] + map.tens[ten] + map.digits[digit]
101+
return result
102+
}
103+
104+
@objc(convertWithString:style:error:)
105+
public static func convert(string: String, style: RomanNumbersStyle = .alphabets) throws
106+
-> String
107+
{
108+
guard let number = Int(string) else {
109+
throw RomanNumbersErrors.invalidInput
110+
}
111+
return try convert(input: number, style: style)
112+
}
113+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Testing
2+
@testable import RomanNumbers
3+
4+
@Suite("RomanNumbersTests")
5+
struct RomanNumbersTests {
6+
7+
@Test("Test alphabets", arguments: [
8+
("0", ""),
9+
("1", "I"),
10+
("3999", "MMMCMXCIX"),
11+
("2000", "MM"),
12+
("2025", "MMXXV")
13+
]) func test(input: String, expected: String ) async throws {
14+
let output = try RomanNumbers.convert(string: input)
15+
#expect(output == expected)
16+
}
17+
18+
@Test("Test Upper cased", arguments: [
19+
("0", ""),
20+
("1", ""),
21+
("3999", "ⅯⅯⅯⅭⅯⅩⅭⅨ"),
22+
("2000", "ⅯⅯ"),
23+
("2025", "ⅯⅯⅩⅩⅤ")
24+
]) func testUpperCase(input: String, expected: String ) async throws {
25+
let output = try RomanNumbers.convert(string: input, style: .fullWidthUpper)
26+
#expect(output == expected)
27+
}
28+
29+
@Test("Test Lower cased", arguments: [
30+
("0", ""),
31+
("1", ""),
32+
("3999", "ⅿⅿⅿⅽⅿⅹⅽⅸ"),
33+
("2000", "ⅿⅿ"),
34+
("2025", "ⅿⅿⅹⅹⅴ")
35+
]) func testLowerCase(input: String, expected: String ) async throws {
36+
let output = try RomanNumbers.convert(string: input, style: .fullWidthLower)
37+
#expect(output == expected)
38+
}
39+
40+
}

Source/Base.lproj/Localizable.strings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,9 @@
132132
"Do you want to boost the score of the phrase \"%@\"?" = "Do you want to boost the score of the phrase \"%@\"?";
133133

134134
"Do you want to exclude the phrase \"%@\"?" = "Do you want to exclude the phrase \"%@\"?";
135+
136+
"Roman Numbers (Alphabets)" = "Roman Numbers (Alphabets)";
137+
138+
"Roman Numbers (Full-width Upper Case)" = "Roman Numbers (Full-width Upper Case)";
139+
140+
"Roman Numbers (Full-width Lower Case)" = "Roman Numbers (Full-width Lower Case)";

0 commit comments

Comments
 (0)