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
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@

## 構成・ビルド

- ライブラリは 3 つ、すべて `netstandard2.0`(SDK スタイル)。ビルドは `dotnet build ZipConvertCustomerBarCode.sln`。
- `ZipConvertCustomerBarCode`: コア。外部依存を追加しない(描画ライブラリに依存させないのが分割の目的)。
- ライブラリは 3 つ(SDK スタイル)。ビルドは `dotnet build ZipConvertCustomerBarCode.sln`。
- `ZipConvertCustomerBarCode`: コア。`netstandard2.0` と `net8.0`。外部依存を追加しない(描画ライブラリに依存させないのが分割の目的。`System.Memory` も入れない)。
- `Span<T>` などは `#if NET8_0_OR_GREATER` の中だけで使い、`#else` に `netstandard2.0` 用の処理を書く。`#if` は小さな範囲(メソッドや構造体の中)に閉じ込め、共通にできる部分は共通のメソッドにする。
- `Span` を使うのは計測で効果があった箇所のみ(住所照合の正規化、CSV の読み込み)。`Span` を使わなくても両方で速くできる場合は、`#if` を使わず共通のコードにする。
- `ZipConvertCustomerBarCode.Drawing`: System.Drawing.Common による描画(Windows のみ)。
- `ZipConvertCustomerBarCode.SkiaSharp`: SkiaSharp による描画。名前空間は `SkiaSharp` との衝突を避けるため `ZipConvertCustomerBarCode.Skia`。
- `netstandard2.0` の既定 C# は 7.3。`record` や null 許容参照型など新しい構文は使わない。
- `netstandard2.0` の既定 C# は 7.3。`#if NET8_0_OR_GREATER` の外では `record` や null 許容参照型など新しい構文は使わない。
- バージョン・著作権表示は `Directory.Build.props` にまとめている。
- 公開メンバーには XML ドキュメントコメントを付ける(`GenerateDocumentationFile` 有効のため、ないと警告になる)。

## テスト

- `dotnet test` で実行(xUnit v3、net10.0)。`global.json` で Microsoft.Testing.Platform を指定しているため、`Microsoft.NET.Test.Sdk` や `xunit.runner.visualstudio` は追加しない。
- コアは `#if` で処理が分かれるため、変更したら両方のビルドでテストする: `dotnet test`(net8.0 版)と `dotnet test -p:CoreTargetFramework=netstandard2.0`。`TargetFrameworkTests` が、指定したビルドが実際に使われていることを確認する。
- `TestData/cases.tsv` と `v1-*.png` は 1.x(.NET Framework 版)の出力で、シンボル列と画像がこれとピクセル単位で一致することを確認している。1.x がマニュアルと異なっていたケース(補足ルール対象など)は除いてあり、それらは `SupplementaryRuleTests` でマニュアルの「バーコード・データ」を期待値にしている。出力が変わる修正をした場合は、マニュアルに照らして意図した変更か確認してから期待値を更新する。
- `cases.tsv` はタブ区切り(郵便番号・住所・シンボル列・画像ファイル名)。住所が空の行があるので、シェルで加工するときは連続するタブがまとめられないよう注意する。
- System.Drawing のテストは Windows 以外ではスキップされる。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[郵便局 - 郵便番号・バーコードマニュアル](http://www.post.japanpost.jp/zipcode/zipmanual/index.html)

## 構成
すべて .NET Standard 2.0 のライブラリです。描画方法に合わせてパッケージを選んでください。
.NET Standard 2.0 のライブラリです(コアは .NET 8 以降向けのビルドも含み、住所の照合や郵便番号データの読み込みでメモリの割り当てが少なくなります)。描画方法に合わせてパッケージを選んでください。

| プロジェクト | 依存 | 用途 |
| --- | --- | --- |
Expand Down
25 changes: 25 additions & 0 deletions ZipConvertCustomerBarCode.Tests/TargetFrameworkTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Reflection;
using System.Runtime.Versioning;

namespace ZipConvertCustomerBarCode.Tests
{
public class TargetFrameworkTests
{
[Fact]
public void 指定したコアのビルドでテストしている()
{
// dotnet test -p:CoreTargetFramework=netstandard2.0 で指定したビルドが実際に使われているか
var expected = typeof(TargetFrameworkTests).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(x => x.Key == "CoreTargetFramework").Value;
var actual = typeof(BarCode).Assembly.GetCustomAttribute<TargetFrameworkAttribute>()!.FrameworkName;

var expectedName = expected switch
{
"netstandard2.0" => ".NETStandard,Version=v2.0",
"net8.0" => ".NETCoreApp,Version=v8.0",
_ => throw new InvalidOperationException($"想定していない CoreTargetFramework: {expected}"),
};
Assert.Equal(expectedName, actual);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,21 @@
<Using Include="Xunit" />
</ItemGroup>

<!--
コアは netstandard2.0 と net8.0 で処理が異なる(#if NET8_0_OR_GREATER)ため、両方をテストする。
通常は net8.0 版、dotnet test -p:CoreTargetFramework=netstandard2.0 で netstandard2.0 版を使う。
-->
<PropertyGroup>
<CoreTargetFramework Condition="'$(CoreTargetFramework)' == ''">net8.0</CoreTargetFramework>
</PropertyGroup>

<ItemGroup>
<!-- テストで、使っているコアのビルドを確認する -->
<AssemblyMetadata Include="CoreTargetFramework" Value="$(CoreTargetFramework)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ZipConvertCustomerBarCode\ZipConvertCustomerBarCode.csproj" />
<ProjectReference Include="..\ZipConvertCustomerBarCode\ZipConvertCustomerBarCode.csproj" SetTargetFramework="TargetFramework=$(CoreTargetFramework)" />
<ProjectReference Include="..\ZipConvertCustomerBarCode.Drawing\ZipConvertCustomerBarCode.Drawing.csproj" />
<ProjectReference Include="..\ZipConvertCustomerBarCode.SkiaSharp\ZipConvertCustomerBarCode.SkiaSharp.csproj" />
</ItemGroup>
Expand Down
98 changes: 90 additions & 8 deletions ZipConvertCustomerBarCode/AddressMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ public int MatchPrefix(string prefix)
/// <returns>一致した部分の終わり(元の住所での位置)。一致しない場合は -1</returns>
public int MatchPrefix(string prefix, int start)
{
#if NET8_0_OR_GREATER
// 正規化した文字列を作らず、スタック上で正規化して比べる(漢数字は1文字が最大4文字になる)
var size = prefix.Length * 4;
Span<char> buffer = size <= 256 ? stackalloc char[size] : new char[size];
return MatchNormalizedPrefix(buffer.Slice(0, NormalizeTo(prefix, buffer)), start);
#else
return MatchNormalizedPrefix(Normalize(prefix), start);
#endif
}

/// <summary>
Expand All @@ -64,12 +71,42 @@ public int MatchPrefix(string prefix, int start)
/// <returns>一致した部分の終わり(元の住所での位置)。一致しない場合は -1</returns>
public int MatchNormalizedPrefix(string target, int start)
{
#if NET8_0_OR_GREATER
return MatchNormalizedPrefix(target.AsSpan(), start);
#else
var offset = NormalizedIndex(start);
if (target.Length == 0) return start;
if (string.CompareOrdinal(normalized, offset, target, 0, target.Length) != 0 || normalized.Length - offset < target.Length) return -1;
if (normalized.Length - offset < target.Length || string.CompareOrdinal(normalized, offset, target, 0, target.Length) != 0) return -1;
return MatchEnd(offset, target.Length);
#endif
}

#if NET8_0_OR_GREATER
/// <summary>
/// 住所の指定位置以降が、正規化済みの町域名などと一致する場合、一致した部分の終わりの位置を返す
/// </summary>
/// <param name="target">正規化済みの町域名など</param>
/// <param name="start">照合を始める位置(元の住所での位置)</param>
/// <returns>一致した部分の終わり(元の住所での位置)。一致しない場合は -1</returns>
public int MatchNormalizedPrefix(ReadOnlySpan<char> target, int start)
{
var offset = NormalizedIndex(start);
if (target.Length == 0) return start;
if (!normalized.AsSpan(offset).StartsWith(target, StringComparison.Ordinal)) return -1;
return MatchEnd(offset, target.Length);
}
#endif

/// <summary>
/// 正規化後の住所で一致した部分の終わりを、元の住所での位置に変換する
/// </summary>
/// <param name="offset">一致した部分の開始位置(正規化後)</param>
/// <param name="length">一致した部分の長さ(正規化後)</param>
/// <returns>一致した部分の終わり(元の住所での位置)。漢数字の途中で終わる場合は -1</returns>
private int MatchEnd(int offset, int length)
{
// 漢数字の途中で終わる場合(「二十」の「2」だけが一致した場合など)は一致としない
var last = offset + target.Length - 1;
var last = offset + length - 1;
if (last + 1 < normalized.Length && ends[last + 1] == ends[last]) return -1;
return ends[last];
}
Expand Down Expand Up @@ -149,16 +186,15 @@ private static void Normalize(string value, StringBuilder sb, List<int> ends)
}

// 漢数字の並びは算用数字に変換する
if (KanjiNumerals.IndexOf(c) >= 0)
var kanjiEnd = KanjiRunEnd(value, i);
if (kanjiEnd > i)
{
int j = i;
while (j < value.Length && KanjiNumerals.IndexOf(value[j]) >= 0) j++;
foreach (var d in BarCode.KanjiToNumber(value.Substring(i, j - i)))
foreach (var d in BarCode.KanjiToNumber(value, i, kanjiEnd - i))
{
sb.Append(d);
ends?.Add(j);
ends?.Add(kanjiEnd);
}
i = j;
i = kanjiEnd;
continue;
}

Expand All @@ -168,6 +204,52 @@ private static void Normalize(string value, StringBuilder sb, List<int> ends)
}
}

#if NET8_0_OR_GREATER
/// <summary>
/// 住所を照合用に正規化して書き込む(<see cref="Normalize(string)"/> の文字列を作らない版)
/// </summary>
/// <param name="value">住所</param>
/// <param name="destination">書き込み先(住所の長さの4倍あれば足りる)</param>
/// <returns>書き込んだ文字数</returns>
private static int NormalizeTo(string value, Span<char> destination)
{
int i = 0, written = 0;
while (i < value.Length)
{
var c = value[i];
if (char.IsWhiteSpace(c))
{
i++;
continue;
}

// 漢数字の並びは算用数字に変換する
var kanjiEnd = KanjiRunEnd(value, i);
if (kanjiEnd > i)
{
written += BarCode.WriteKanjiNumber(value, i, kanjiEnd - i, destination.Slice(written));
i = kanjiEnd;
continue;
}

destination[written++] = NormalizeChar(c);
i++;
}
return written;
}
#endif

/// <summary>
/// 指定位置から漢数字が続く場合、その終わりの位置を返す
/// </summary>
/// <returns>漢数字の並びの終わりの位置。漢数字で始まらない場合は指定位置</returns>
private static int KanjiRunEnd(string value, int start)
{
int end = start;
while (end < value.Length && KanjiNumerals.IndexOf(value[end]) >= 0) end++;
return end;
}

/// <summary>
/// 元の住所での位置を正規化後の位置に変換する
/// </summary>
Expand Down
95 changes: 81 additions & 14 deletions ZipConvertCustomerBarCode/BarCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,19 +202,24 @@ public static CustomerBarCode Create(string zipCode, string townAddress, string
/// <returns>郵便番号(半角数字7桁)</returns>
internal static string CheckPostCode(string zipCode)
{
// 全角→半角
zipCode = string.Join("", zipCode.Select(ToHankaku));

// ハイフン除去
zipCode = zipCode.Replace("-", "");

// \d は全角以外の Unicode 数字にも、\Z は末尾の改行にもマッチするため使わない
if (!Regex.IsMatch(zipCode, @"\A[0-9]{7}\z"))
// 全角→半角にし、ハイフンを除いて半角数字(0~9 のみ。全角以外の Unicode 数字や改行は不可)が7桁であること
var digits = new char[7];
var count = 0;
foreach (var c in zipCode)
{
throw new ArgumentException("郵便番号は7桁の数値で入力してください。");
var hankaku = ToHankaku(c);
if (hankaku == '-') continue;
if (hankaku < '0' || hankaku > '9' || count == digits.Length) throw InvalidPostCode();
digits[count++] = hankaku;
}
if (count != digits.Length) throw InvalidPostCode();

return zipCode;
return new string(digits);
}

private static ArgumentException InvalidPostCode()
{
return new ArgumentException("郵便番号は7桁の数値で入力してください。");
}

/// <summary>
Expand Down Expand Up @@ -273,14 +278,76 @@ private static string ExtractAddressNumber(string address)
/// <returns>算用数字</returns>
internal static string KanjiToNumber(string kanji)
{
if (kanji.IndexOfAny(new[] { '十', '百', '千' }) < 0)
return KanjiToNumber(kanji, 0, kanji.Length);
}

/// <summary>
/// 文字列の一部の漢数字を算用数字に変換する
/// </summary>
/// <param name="value">文字列</param>
/// <param name="start">漢数字の開始位置</param>
/// <param name="length">漢数字の長さ</param>
/// <returns>算用数字</returns>
internal static string KanjiToNumber(string value, int start, int length)
{
if (!HasKanjiUnit(value, start, length))
{
var digits = new char[length];
for (int i = 0; i < length; i++) digits[i] = KanjiDigit(value[start + i]);
return new string(digits);
}
return KanjiValue(value, start, length).ToString(CultureInfo.InvariantCulture);
}

#if NET8_0_OR_GREATER
/// <summary>
/// 文字列の一部の漢数字を算用数字に変換して書き込む
/// </summary>
/// <param name="value">文字列</param>
/// <param name="start">漢数字の開始位置</param>
/// <param name="length">漢数字の長さ</param>
/// <param name="destination">書き込み先(漢数字の長さの4倍あれば足りる)</param>
/// <returns>書き込んだ文字数</returns>
internal static int WriteKanjiNumber(string value, int start, int length, Span<char> destination)
{
if (!HasKanjiUnit(value, start, length))
{
return string.Concat(kanji.Select(c => KanjiDigits.IndexOf(c)));
for (int i = 0; i < length; i++) destination[i] = KanjiDigit(value[start + i]);
return length;
}
KanjiValue(value, start, length).TryFormat(destination, out var written, default, CultureInfo.InvariantCulture);
return written;
}
#endif

// 位取りの漢数字
private static readonly char[] KanjiUnits = { '十', '百', '千' };

/// <summary>
/// 位取り(十、百、千)を含むかどうか(含まない場合は「二〇五」のように1文字ずつ数字にする)
/// </summary>
private static bool HasKanjiUnit(string value, int start, int length)
{
return value.IndexOfAny(KanjiUnits, start, length) >= 0;
}

/// <summary>
/// 位取りなしの漢数字1文字を算用数字にする
/// </summary>
private static char KanjiDigit(char c)
{
return (char)('0' + KanjiDigits.IndexOf(c));
}

/// <summary>
/// 位取りを含む漢数字(「十一」「千二百三十四」)の値
/// </summary>
private static int KanjiValue(string value, int start, int length)
{
int total = 0, current = 0;
foreach (var c in kanji)
for (int i = start; i < start + length; i++)
{
var c = value[i];
var digit = KanjiDigits.IndexOf(c);
if (digit >= 0)
{
Expand All @@ -291,7 +358,7 @@ internal static string KanjiToNumber(string kanji)
total += (current == 0 ? 1 : current) * unit;
current = 0;
}
return (total + current).ToString(CultureInfo.InvariantCulture);
return total + current;
}

/// <summary>
Expand Down
Loading