前置き(ブログの移転について)
前のブログ(というか法律事務所公式サイト)はレンタルサーバーを借りて自分でWordPressを設置していたのに、いつしかWordPressの更新もせずに放置してしまっていて、このままではいけないという気がしたのでブログを(放置前提で)Bloggerに移すことにする。
過去記事を全部Bloggerに移そうかとも思ったが、面倒すぎたし(相当性の欠如)、今更それらの記事を公開することに私の中で意義を見出だせもしなかったので(必要性の欠如)、技術的な記事だけBloggerに持ってきた。
もしかしたら、即独日記として、即独したい司法修習生の参考になっていたかもしれない気もしないではない。ログを一括してxmlでダウンロードしてはあるので、参考にしたい修習生がいたらメールくれたら送るが、あまり参考にならないと思うし、xmlファイルでもらっても困るかもしれない。
本題の前置き(裁判文書整形用macOSアプリについて)
さて、旧ブログから持ってきた「技術的な記事」は、①テキストエディタmiで文法定義スクリプトを使ってプレーンテキストで文章を起案し、②そのプレーンテキストをコピーしてクリップボードに入れてから、スクリプトでLibreOfficeやらMS Wordやらで自動整形して裁判文書(等)の形に整える、という私のワークフローで使うソースコードを公開するというものであった。
今は、前記①の工程は変わらないが、前記②の工程でワープロソフトを使わずに自作のmacOS用アプリでいきなりPDFにしてしまうということをしている。
つまり、クリップボードにテキストを入れ、この自作アプリ(KianPrintと名付けている)でボタンを押せば即インデントとかで整形されたPDFが出てくる。そういうアプリを作っているので、一応これもソースコードを公開しておく。
仕様については、過去記事にあるスクリプトとだいたい同じなので過去記事をみればだいたいわかると思う。違いは、1ページしかない文書ではページ番号が付かないとか、改ページ機能が実装されたとか、そのぐらいか。ワープロソフトを介さないせいで必要になった機能である。UIのスクショだけ上げておこう。私が何をしているか、なんとなくわかると思う。
アプリを公開するのに.appの形でなくソースコードだけ置かれても困るかもしれないが、困ったらそのへんのAIに聞くとよいと思う。2025年6月現在ならChatGPTがオススメである。彼は、できるかできないかにかかわらず「簡単にできるよ!やってみようよ!」と励ましてくれるので、やる気は出る。
このアプリについて、一つだけ心残りはあって、それは、禁則処理の仕方が裁判所(おそらくMS Wordのデフォルト)と違うということである。このアプリは禁則処理をAppleのTextKitというエンジンに任せっきりにしている。ここをわざわざ自分で書く忍耐力が私にはなかった。なのでこのアプリを使うと、改行のされ方が、MacやiPhoneでよく見るやつ、つまり、禁則処理を全部「追い出し」でなんとかしてしまうというものになる。許せ。
本題(ソースコード自体)
ContentView.swift
import SwiftUI
import AppKit
@main
struct KianPrintApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// 起動時にメインウィンドウを探して delegate をセット
if let window = NSApplication.shared.windows.first {
window.delegate = self
}
}
func windowWillClose(_ notification: Notification) {
// 最後のウィンドウが閉じられたらアプリ終了
NSApplication.shared.terminate(nil)
}
}
struct ContentView: View {
@State private var isCourtStyle: Bool = true
@State private var topMargin: String = "35"
@State private var bottomMargin: String = "27"
@State private var leftMargin: String = "30"
@State private var rightMargin: String = "22" //本当は20だがカーンを0にするとalign右の行が出っ張って見えるのでここを犠牲にして調整
@State private var kern: String = "0" //文字によって幅が違うからぎりぎりを狙うと36文字の行が出てくるのでそうならないギリギリを手作業でさぐった結果の値が0.18でありその値を設定してみたこともあったがデフォルトが一番読みやすかったので0にしておく
@State private var spacing: String = "13.62" //余白を除いた縦幅を厳密に26分割してフォントサイズ12を前提に算出した値
@State private var selectedFontSize: String = "12"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Use Court Style", isOn: $isCourtStyle)
VStack {
HStack {
Text("Top Margin (mm)")
Spacer()
TextField("", text: $topMargin)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
HStack {
Text("Bottom Margin (mm)")
Spacer()
TextField("", text: $bottomMargin)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
HStack {
Text("Left Margin (mm)")
Spacer()
TextField("", text: $leftMargin)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
HStack {
Text("Right Margin (mm)")
Spacer()
TextField("", text: $rightMargin)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
HStack {
Text("Kern")
Spacer()
TextField("", text: $kern)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
HStack {
Text("Line Spacing")
Spacer()
TextField("", text: $spacing)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
HStack {
Text("Default Font Size")
Spacer()
TextField("", text: $selectedFontSize)
.disabled(isCourtStyle)
.textFieldStyle(.roundedBorder)
.frame(width: 55)
.multilineTextAlignment(.center)
}
}
HStack {
Spacer()
Button("PDF from Clipboard") {
if let clipboardText = NSPasteboard.general.string(forType: .string) {
let settings = PDFSettings(
topMargin: isCourtStyle ? 35.0 : (Double(topMargin) ?? 35.0),
bottomMargin: isCourtStyle ? 27.0 : (Double(bottomMargin) ?? 27.0),
leftMargin: isCourtStyle ? 30.0 : (Double(leftMargin) ?? 30.0),
rightMargin: isCourtStyle ? 22.0 : (Double(rightMargin) ?? 22.0),
kern: isCourtStyle ? 0 : (Double(kern) ?? 0),
spacing: isCourtStyle ? 13.62 : (Double(spacing) ?? 13.62),
isCourtStyle: isCourtStyle,
defaultFontSize: isCourtStyle ? 12 : (Double(selectedFontSize) ?? 12)
)
let pdfData = PDFGenerator.generate(from: clipboardText, settings: settings)
let tempDir = FileManager.default.temporaryDirectory
let tempURL = tempDir.appendingPathComponent("KianPrint.pdf")
do {
try pdfData.write(to: tempURL)
NSWorkspace.shared.open(tempURL)
} catch {
print("Failed to save or open PDF: \(error)")
}
}
}
.keyboardShortcut(.defaultAction)
Spacer()
}
}
.frame(width: 190)
.padding()
}
}
Paragraph.swift
import Foundation
import AppKit
struct Paragraph {
let text: String
let indent: CGFloat
let firstLineIndent: CGFloat
let fontSize: CGFloat
let alignment: NSTextAlignment
}
PDFGenerator.swift
import Foundation
import PDFKit
enum PDFGenerator {
static func generate(from text: String, settings: PDFSettings) -> Data {
let pdfData = NSMutableData()
guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
let pageSize = CGSize(width: 210 / 25.4 * 72, height: 297 / 25.4 * 72) //ここで厳密にmm→ptを計算
var mediaBoxRect = CGRect(origin: .zero, size: pageSize)
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBoxRect, nil) else { return Data() }
let topMargin = settings.topMarginPt
let bottomMargin = settings.bottomMarginPt
let leftMargin = settings.leftMarginPt
let rightMargin = settings.rightMarginPt
let defaultFontSize = settings.defaultFontSize
let kern = settings.kern
let spacing = settings.spacing
let besshies = text.components(separatedBy: "\n【改ページ】\n") //【改ページ】の行には他に何もあってはならない
var currentPage = 1
for besshi in besshies {
//この分割の際に「第n 」又は「第n条」で始まるかそうでないかを判別するので、この判別は【改ページ】の前後で別
let paragraphs = PDFParagraphParser.parseParagraphs(from: besshi, settings: settings)
// テキストとレイアウトの準備
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
for para in paragraphs {
let style = NSMutableParagraphStyle()
style.firstLineHeadIndent = para.indent + para.firstLineIndent
style.headIndent = para.indent
style.lineSpacing = spacing - ( para.fontSize - defaultFontSize) //大きい文字を指定した行はその分ぴったり行間も減算
style.alignment = para.alignment
let attributes: [NSAttributedString.Key: Any] = [
.kern: kern * (para.fontSize / defaultFontSize), //大きい文字を指定した行のカーンは比率を乗算
.font: NSFont(name: "Hiragino Mincho ProN W3", size: para.fontSize) ?? .systemFont(ofSize: para.fontSize),
.paragraphStyle: style
]
let attr = NSAttributedString(string: para.text + "\n", attributes: attributes)
textStorage.append(attr)
}
var glyphRange = NSRange(location: 0, length: 0)
//1ページごとの描画
while glyphRange.upperBound < layoutManager.numberOfGlyphs {
context.beginPDFPage(nil)
context.translateBy(x: 0, y: pageSize.height)
context.scaleBy(x: 1.0, y: -1.0)
let textContainer = NSTextContainer(size: CGSize(width: pageSize.width - leftMargin - rightMargin - kern, height: pageSize.height - topMargin - bottomMargin - spacing)) //余白すれすれまで描画するのではなく、カーンと行間のそれぞれ半分が字の周囲にあるものとしてそこを除いた範囲を描画範囲とする
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
let pageRange = layoutManager.glyphRange(for: textContainer)
let graphicsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.current = graphicsContext
let textOrigin = CGPoint(x: leftMargin + (kern / 2), y: topMargin + (spacing / 2)) //余白の端ぴったりではなく、そこから字の周囲にあるべき隙間(カーンと行間それぞれの半分)を空けて開始
layoutManager.drawGlyphs(forGlyphRange: pageRange, at: textOrigin)
glyphRange = NSRange(location: pageRange.upperBound, length: 0)
// ↓↓↓ ページ番号描画 ↓↓↓
if currentPage > 1 || glyphRange.upperBound < layoutManager.numberOfGlyphs || besshies.count > 1 {
let pageNumber = "\(currentPage)"
let footerStyle = NSMutableParagraphStyle()
footerStyle.firstLineHeadIndent = 0
footerStyle.headIndent = 0
footerStyle.lineSpacing = 0
footerStyle.alignment = .center
let attr = NSAttributedString(
string: pageNumber,
attributes: [.font: NSFont(name: "Hiragino Mincho ProN W3", size: settings.defaultFontSize - 1) ?? .systemFont(ofSize: settings.defaultFontSize - 1), .paragraphStyle: footerStyle]
)
//footerX,FooterYで指定されるのは描画の左上点なので
let footerX = (leftMargin + ((pageSize.width - leftMargin - rightMargin) / 2)) - attr.size().width / 2 //余白を除いた部分の中央(ここを文字枠の中央とする)より文字枠の半分だけ左に
let footerY = mediaBoxRect.height - (bottomMargin * 0.7) - (attr.size().height / 2) //下余白部の下から7割のところ(ここを文字枠の中央とする)より文字枠の半分だけ上に
attr.draw(at: CGPoint(x: footerX, y: footerY))
}
// ↑↑↑ ページ番号描画 ↑↑↑
context.endPDFPage()
currentPage += 1
}
}
context.closePDF()
return pdfData as Data
}
}
PDFParagraphParser.swift
import Foundation
import AppKit
enum PDFParagraphParser {
static func parseParagraphs(from text: String, settings: PDFSettings) -> [Paragraph] {
let paras = text.components(separatedBy: .newlines)
let fs = CGFloat(settings.defaultFontSize)
var result: [Paragraph] = []
var indents: [CGFloat] = []
let hasDaiPattern = paras.contains { para in
para.range(of: "^第[0-9]+[条 ]", options: .regularExpression) != nil
}
for (idx, para) in paras.enumerated() {
let original = para
let trimmed = para.trimmingCharacters(in: .whitespaces)
var indent: CGFloat = 0
var firstLineIndent: CGFloat = 0
var bigFs: CGFloat = fs
var alignment: NSTextAlignment = .left
// 空行は空の段落として追加(表示用の空行)
if original.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
result.append(Paragraph(
text: "",
indent: 0,
firstLineIndent: 0,
fontSize: bigFs,
alignment: .left
))
indents.append(0)
continue
}
// 中央・右揃え設定とフォントサイズ調整
if original.hasPrefix(" ") && original.hasSuffix(" ") {
alignment = .center; bigFs = fs * (18 / 12)
} else if original.hasPrefix(" ") && original.hasSuffix(" ") {
alignment = .center; bigFs = fs * (16 / 12)
} else if original.hasPrefix(" ") && original.hasSuffix(" ") {
alignment = .center; bigFs = fs * (14 / 12)
} else if original.hasPrefix(" ") && original.hasSuffix(" ") {
alignment = .center
} else if original.hasPrefix(" ") && original.hasSuffix(" ") {
alignment = .right
}
if alignment == .center || alignment == .right {
result.append(Paragraph(text: trimmed, indent: 0, firstLineIndent: 0, fontSize: bigFs, alignment: alignment))
indents.append(0)
continue
}
// インデント処理
let adjustment: CGFloat = hasDaiPattern ? 0 : -1 * fs
if original.range(of: #"^第[0-9]+条"#, options: .regularExpression) != nil {
indent = 2 * fs; firstLineIndent = -2 * fs
} else if original.range(of: #"^第[0-9]+ "#, options: .regularExpression) != nil {
indent = 2 * fs + adjustment; firstLineIndent = -2 * fs
} else if original.range(of: #"^[0-9]+ "#, options: .regularExpression) != nil {
indent = 2 * fs + adjustment; firstLineIndent = -1 * fs
} else if original.range(of: #"^[0-9]+\([0-9]+\) "#, options: .regularExpression) != nil {
indent = 3 * fs + adjustment; firstLineIndent = -2 * fs
} else if original.range(of: #"^\([0-9]+\) "#, options: .regularExpression) != nil {
indent = 3 * fs + adjustment; firstLineIndent = -1 * fs
} else if original.range(of: #"^\([0-9]+\)[ア-ン] "#, options: .regularExpression) != nil {
indent = 4 * fs + adjustment; firstLineIndent = -2 * fs
} else if original.range(of: #"^[ア-ン] "#, options: .regularExpression) != nil {
indent = 4 * fs + adjustment; firstLineIndent = -1 * fs
} else if original.range(of: #"^[ア-ン]\([ア-ン]\) "#, options: .regularExpression) != nil {
indent = 5 * fs + adjustment; firstLineIndent = -2 * fs
} else if original.range(of: #"^\([ア-ン]\) "#, options: .regularExpression) != nil {
indent = 5 * fs + adjustment; firstLineIndent = -1 * fs
} else if original.range(of: #"^\([ア-ン]\)[a-z] "#, options: .regularExpression) != nil {
indent = 6 * fs + adjustment; firstLineIndent = -2 * fs
} else if original.range(of: #"^[a-z] "#, options: .regularExpression) != nil {
indent = 6 * fs + adjustment; firstLineIndent = -1 * fs
} else if original.range(of: #"^[a-z]\([a-z]\) "#, options: .regularExpression) != nil {
indent = 7 * fs + adjustment; firstLineIndent = -2 * fs
} else if original.range(of: #"^\([a-z]\) "#, options: .regularExpression) != nil {
indent = 7 * fs + adjustment; firstLineIndent = -1 * fs
} else if original.range(of: #"^[①-⑳] "#, options: .regularExpression) != nil { //丸
if idx > 0, paras[idx - 1].range(of: #"^[①-⑳] "#, options: .regularExpression) != nil { //&1つ前に丸
indent = indents.last ?? (1 * fs) //1つ前。なければ(あり得ない)1段階
} else { //&(1つ前がそれ以外 or 1つ前が不存在)
indent = (indents.last ?? 0) + 1 * fs //1つ前より1段階足す、なければ1段階
}
firstLineIndent = -1 * fs //丸の場合はぶら下がりインデント
} else if original.hasPrefix(" ") && !trimmed.isEmpty { //冒頭" "で非空行
if idx > 0, paras[idx - 1].range(of: #"^[①-⑳] "#, options: .regularExpression) != nil { //&1つ前に丸
indent = 1 * fs //非丸不発見時、1段階
for j in stride(from: idx - 1, through: 0, by: -1) { //1つ前から戻っていく
if paras[j].range(of: #"^[①-⑳] "#, options: .regularExpression) == nil { //非丸発見時
indent = indents[j] //非丸発見段落のインデント
break
}
}
} else if idx > 0, (indents.last ?? 1 * fs) < 1 * fs { //&1つ前にデフォルト段落
indent = 1 * fs //1段階
} else{
indent = indents.last ?? (1 * fs) //原則形態、1つ前。なければ1段階。
}
firstLineIndent = -1 * fs //冒頭" "で非空の場合はぶら下がりインデント
}
result.append(Paragraph(text: original, indent: indent, firstLineIndent: firstLineIndent, fontSize: fs, alignment: alignment))
indents.append(indent)
}
return result
}
}
PDFSettings.swift
import Foundation
struct PDFSettings {
let topMargin: Double // in mm
let bottomMargin: Double // in mm
let leftMargin: Double // in mm
let rightMargin: Double // in mm
let kern: Double
let spacing: Double
let isCourtStyle: Bool
let defaultFontSize: Double
var topMarginPt: CGFloat {
return topMargin / 25.4 * 72
}
var bottomMarginPt: CGFloat {
return bottomMargin / 25.4 * 72
}
var leftMarginPt: CGFloat {
return leftMargin / 25.4 * 72
}
var rightMarginPt: CGFloat {
return rightMargin / 25.4 * 72
}
var textWidth: CGFloat {
let pageWidth: CGFloat = 595.2
return pageWidth - leftMarginPt - rightMarginPt
}
}
