ホーム 記事一覧 ブック DH週間トピックス 検索 このサイトについて
English
GakuNin RDM Waterbutler API でアップロードしたファイルの詳細画面に遷移する方法

GakuNin RDM Waterbutler API でアップロードしたファイルの詳細画面に遷移する方法

Waterbutler API とは Waterbutler は、Center for Open Science (COS) が開発したファイルストレージ抽象化レイヤーです。OSF (Open Science Framework) および GakuNin RDM で使用されており、様々なストレージプロバイダー(OSF Storage、Amazon S3、Google Drive、Dropbox など)に対して統一的なAPIでファイル操作を行うことができます。 主な機能 ファイルのアップロード・ダウンロード ファイル/フォルダの作成・削除・移動・コピー メタデータの取得 エンドポイント GakuNin RDM : https://files.rdm.nii.ac.jp/v1 OSF : https://files.osf.io/v1 参考リンク Waterbutler GitHub OSF API Documentation 問題 GakuNin RDM の Waterbutler API を使用してファイルをアップロードした後、そのファイルの詳細画面に直接遷移したい場合があります。 しかし、RDM上ではプロジェクトの短縮URL(例:https://rdm.nii.ac.jp/qv3xf/)が表示されるため、アップロードしたファイルの詳細画面URLをどのように構成すればよいかわかりにくい状況でした。 Waterbutler API のレスポンス ファイルアップロード時の Waterbutler API レスポンス例: { "data": { "id": "osfstorage/67dacaa816000900109e1da3", "type": "files", "attributes": { "name": "nft-43-provenance-2025-12-29T13-08-47.zip", "kind": "file", "path": "/67dacaa816000900109e1da3" }, "links": { "download": "https://files.rdm.nii.ac.jp/v1/resources/wzv9g/providers/osfstorage/67dacaa816000900109e1da3", "move": "https://files.rdm.nii.ac.jp/v1/resources/wzv9g/providers/osfstorage/67dacaa816000900109e1da3" } } } 解決策:ファイル詳細URLの構成 ファイル詳細画面のURLは以下の形式で構成します: ...

Pinata V3 API グループ機能の実装ガイド

Pinata V3 API グループ機能の実装ガイド

Pinata の Files API v3 でグループ機能を使用する際のはまりポイントと解決策をまとめます。 背景 Pinata でアップロードしたファイルをグループで管理し、特定のグループに属するファイルのみを取得したいケースがあります。例えば、NFT登録フォームで使用する入力画像を「input」グループに格納し、そのグループからのみ画像を選択できるようにする場合などです。 はまりポイント 1. レガシーAPI と V3 API のファイル管理は分離されている 問題 : レガシーAPI(pinFileToIPFS)でアップロードしたファイルは、V3 API(/v3/files)では取得できません。逆も同様です。 レガシーAPI (pinList) → レガシーでアップロードしたファイルのみ表示 V3 API (/v3/files) → V3でアップロードしたファイルのみ表示 解決策 : どちらかのAPIに統一する。V3 APIに移行する場合は、新規アップロードからV3を使用し、既存ファイルは手動でグループに追加するか、マイグレーションを検討。 2. V3 API のエンドポイントには {network} パラメータが必要 問題 : V3 API のエンドポイントは {network} パラメータ(public または private)が必須。 ❌ GET /v3/files?group={group_id} ✅ GET /v3/files/public?group={group_id} 解決策 : 通常のIPFSファイルには public を使用。 3. グループフィルタのパラメータ名が異なる 問題 : アップロード時とリスト取得時でパラメータ名が異なる。 操作 パラメータ名 アップロード group_id リスト取得 group 4. JWT の種類による認証の違い 問題 : Scoped Key で生成した JWT は V3 API で動作しない場合がある。 ...

さくらレンタルサーバー Drupal 更新手順

さくらレンタルサーバー Drupal 更新手順

さくらのレンタルサーバーでDrupal 10.1.5から10.6.1へアップデートした際の手順をまとめます。 環境 サーバー:さくらのレンタルサーバー Drupal:10.1.5 → 10.6.1 インストール形式:従来型(tarball、web/ディレクトリなし) 事前準備 作業用ディレクトリの作成 バックアップは www 外に保存します(Webからアクセスできないようにするため)。 mkdir -p /home/[ユーザー名]/backups/drupal ファイルのバックアップ cd /home/[ユーザー名]/www tar -czvf /home/[ユーザー名]/backups/drupal/drupal_backup_$(date +%Y%m%d).tar.gz [drupalディレクトリ]/ データベースのバックアップ さくらのレンタルサーバーでは --no-tablespaces オプションが必要です。 cd /home/[ユーザー名]/www/[drupalディレクトリ] ./vendor/bin/drush sql-dump --extra-dump="--no-tablespaces" > /home/[ユーザー名]/backups/drupal/db_backup_$(date +%Y%m%d).sql Drupalコアのアップデート 権限の変更 sites/default ディレクトリは書き込み禁止になっていることが多いため、一時的に変更します。 chmod 755 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default chmod 644 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default/default.services.yml Composerでアップデート さくらのレンタルサーバーでは composer コマンドが使えないため、composer.phar を使用します。 cd /home/[ユーザー名]/www/[drupalディレクトリ] php composer.phar require drupal/core-recommended:^10 drupal/core-composer-scaffold:^10 --update-with-dependencies データベース更新とキャッシュクリア ./vendor/bin/drush updatedb -y ./vendor/bin/drush cache:rebuild 権限を戻す chmod 555 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default chmod 444 /home/[ユーザー名]/www/[drupalディレクトリ]/sites/default/settings.php トラブルシューティング Rulesモジュールの互換性エラー アップデート後に以下のようなエラーが出る場合: ...

IIIF Manifestから各巻の冒頭ページを抽出するツールを作成しました

IIIF Manifestから各巻の冒頭ページを抽出するツールを作成しました

はじめに IIIF(International Image Interoperability Framework)を利用したデジタルアーカイブでは、複数巻や複数章で構成される資料を1つのManifestにまとめることがあります。このような場合、各巻・各章の冒頭ページへのリンクを作成したいというニーズがあります。 今回、IIIF Manifestから各巻(range/structure)のlabel と最初のCanvas URL を抽出するシンプルなWebツールを作成しました。 ツールURL : https://nakamura196.github.io/iiif-manifest-extractor/ GitHub : https://github.com/nakamura196/iiif-manifest-extractor 機能 複数のManifest URLを一括処理(1行に1つのURL) 各巻・各章のlabelと最初のCanvas URLを一覧表示 CSV/JSON形式でのエクスポート 処理進捗のリアルタイム表示 使い方 ツールを開く Manifest URLをテキストエリアに入力(複数行可) 「抽出」ボタンをクリック 結果が表形式で表示される 必要に応じてCSV/JSONでダウンロード サンプル 以下のManifest URLで動作を確認できます。複数URLを入力することで、一括処理の動作も確認できます。 国立国会図書館デジタルコレクション「校異源氏物語」: https://dl.ndl.go.jp/api/iiif/3437686/manifest.json https://dl.ndl.go.jp/api/iiif/3437687/manifest.json これらのManifestは源氏物語の各帖(きりつほ、ははきゝ、うつせみ、わかむらさき…など)がstructuresに定義されており、各帖の冒頭ページを抽出できます。 2つのManifest URLを入力することで、複数のManifestを一括で処理し、結果をまとめてCSV出力できることを確認できます。 技術的な仕組み IIIF Presentation API v2のstructures IIIF Presentation API v2では、structuresプロパティを使って論理的な構造(目次)を定義できます。 { "structures": [ { "@type": "sc:Range", "label": "目次", "ranges": ["range1", "range2", ...] }, { "@id": "range1", "@type": "sc:Range", "label": "きりつほ", "canvases": [ "https://example.com/canvas/p1", "https://example.com/canvas/p2", ... ] } ] } 本ツールでは、structures内の各rangeから: label(巻名・章名など) canvases配列の最初の要素(冒頭ページのCanvas URL) を抽出しています。 ...

CloudFront + App Runner で 404 エラーが発生する問題の調査記録

CloudFront + App Runner で 404 エラーが発生する問題の調査記録

はじめに AWS App Runner で Cantaloupe(IIIF画像サーバー)をホストし、その前段に CloudFront を配置しようとしたところ、CloudFront 経由でアクセスすると全てのリクエストが 404 エラーになる問題に遭遇しました。 本記事では、問題の原因調査から試した解決策、そして結論までを記録します。 環境 アプリケーション : Cantaloupe 5.0.5(IIIF画像サーバー) ホスティング : AWS App Runner CDN : Amazon CloudFront リージョン : ap-northeast-1(東京) 問題の概要 症状 アクセス方法 結果 App Runner に直接アクセス 200 OK CloudFront 経由でアクセス 404 Not Found 確認したこと CloudFront 経由で 404 が返る際、レスポンスヘッダーに server: envoy が含まれていました。これは App Runner の内部プロキシ(Envoy)に到達していることを示しています。 $ curl -I https://xxxxx.cloudfront.net/ HTTP/2 404 server: envoy x-cache: Error from cloudfront つまり、CloudFront → App Runner の通信は成功しているが、App Runner 内部でリクエストがアプリケーション(Cantaloupe)に転送されていないことがわかりました。 ...

Nuxt 4 SSGでローカルJSONファイルを正しく読み込む方法

Nuxt 4 SSGでローカルJSONファイルを正しく読み込む方法

はじめに Nuxt 4でStatic Site Generation (SSG) を行う際、ローカルのJSONファイルからデータを読み込んで静的ページを生成したいケースがあります。しかし、Next.jsのgetStaticPropsのようにシンプルにはいかず、いくつかのハマりポイントがあります。 本記事では、試行錯誤の末に見つけた正しいアプローチを紹介します。 問題:なぜ単純なfsの読み込みでは動かないのか 最初に試したアプローチ(失敗) // ❌ これは動かない const fetchLocalData = async (filePath: string) => { if (import.meta.server) { const fs = await import('fs'); const path = await import('path'); const fullPath = path.resolve(process.cwd(), 'public/data', filePath); const data = fs.readFileSync(fullPath, 'utf-8'); return JSON.parse(data); } // クライアントサイド const response = await fetch(`/data/${filePath}`); return await response.json(); }; このアプローチには以下の問題があります: process.cwd()がビルド環境で異なる: ローカル開発とVercel等のビルド環境では作業ディレクトリが異なる Nitroのプリレンダリング時にファイルが見つからない : SSG時、Nitroは独自のコンテキストで動作する useAsyncDataなしでは、クライアントサイドでも実行される : SSGの意味がなくなる 解決策:Nitro Storage API + Server APIルート + useAsyncData アーキテクチャ [SSGビルド時] Page Component ↓ useAsyncData Server API Route (/api/local-data/[...path]) ↓ useStorage (Nitro Storage API) public/data/*.json [生成されたHTML] _payload.json にデータが埋め込まれる → クライアントサイドでのJSON読み込み不要 Step 1: nuxt.config.tsでserverAssetsを設定 // nuxt.config.ts export default defineNuxtConfig({ nitro: { prerender: { crawlLinks: true, failOnError: false, }, // Nitro Storage APIでpublic/dataをサーバーアセットとしてマウント serverAssets: [{ baseName: 'data', dir: './public/data' }], }, routeRules: { '/**': { prerender: true }, }, }); Step 2: Server APIルートを作成(Nitro Storage + fsフォールバック) // server/api/local-data/[...path].ts import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; export default defineEventHandler(async (event) => { const pathParam = getRouterParam(event, 'path'); if (!pathParam) { throw createError({ statusCode: 400, message: 'Path is required' }); } const filePath = Array.isArray(pathParam) ? pathParam.join('/') : pathParam; // セキュリティ: パストラバーサル防止 if (filePath.includes('..')) { throw createError({ statusCode: 400, message: 'Invalid path' }); } // Nitro Storage APIを使用 // nuxt.config.tsのserverAssetsで定義した'data'ストレージにアクセス const storage = useStorage('assets:data'); // ファイルパスをストレージキーに変換(/を:に置換) const storageKey = filePath.replace(/\//g, ':'); // Storage APIで取得を試みる if (await storage.hasItem(storageKey)) { const data = await storage.getItem(storageKey); return data; } // フォールバック: 開発環境向けにfsで直接読み込み const fsPath = resolve(process.cwd(), 'public/data', filePath); if (existsSync(fsPath)) { const data = readFileSync(fsPath, 'utf-8'); return JSON.parse(data); } throw createError({ statusCode: 404, message: `File not found: ${filePath}` }); }); Nitro Storage APIを使うメリット: ...

MapLibre GL JS + れきちず で多言語対応の歴史地図を実装する

MapLibre GL JS + れきちず で多言語対応の歴史地図を実装する

歴史地図サービス「れきちず」が多言語対応(日本語・ひらがな・英語)したことを受けて、MapLibre GL JS で言語切り替えに対応した地図を実装する方法を紹介します。 れきちずとは れきちずは、江戸時代後期(1800〜1840年ごろ)の地図を現代風のデザインで閲覧できるWebサービスです。2025年11月に多言語対応が追加され、以下の3つのスタイルが提供されています。 言語 スタイルURL 日本語 https://mierune.github.io/rekichizu-style/styles/street/style.json ひらがな https://mierune.github.io/rekichizu-style/styles/street/style_hira.json 英語 https://mierune.github.io/rekichizu-style/styles/street/style_en.json シンプルなHTML版 フレームワークを使わず、素のHTML + JavaScriptだけで実装する例です。GETパラメータ(?lang=en)に応じて表示言語を切り替えます。 完全なHTMLファイル <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>れきちず 多言語対応デモ</title> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" /> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: sans-serif; } #map { width: 100%; height: calc(100vh - 50px); } .controls { height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f5f5f5; border-bottom: 1px solid #ddd; } .lang-btn { padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; font-size: 14px; transition: all 0.2s; } .lang-btn:hover { background: #e0e0e0; } .lang-btn.active { background: #2196F3; color: white; border-color: #2196F3; } </style> </head> <body> <div class="controls"> <button class="lang-btn" data-lang="ja">日本語</button> <button class="lang-btn" data-lang="ja-Hira">ひらがな</button> <button class="lang-btn" data-lang="en">English</button> </div> <div id="map"></div> <script> // れきちずのスタイルURL const STYLES = { 'ja': 'https://mierune.github.io/rekichizu-style/styles/street/style.json', 'ja-Hira': 'https://mierune.github.io/rekichizu-style/styles/street/style_hira.json', 'en': 'https://mierune.github.io/rekichizu-style/styles/street/style_en.json' }; // GETパラメータから言語を取得 function getLangFromUrl() { const params = new URLSearchParams(window.location.search); const lang = params.get('lang'); return STYLES[lang] ? lang : 'ja'; // デフォルトは日本語 } // 現在の言語 let currentLang = getLangFromUrl(); // 地図を初期化 const map = new maplibregl.Map({ container: 'map', style: STYLES[currentLang], center: [139.7671, 35.6812], // 東京駅 zoom: 12 }); map.addControl(new maplibregl.NavigationControl(), 'top-right'); // ボタンのアクティブ状態を更新 function updateButtons() { document.querySelectorAll('.lang-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.lang === currentLang); }); } // URLを更新(履歴に追加) function updateUrl(lang) { const url = new URL(window.location); url.searchParams.set('lang', lang); window.history.pushState({}, '', url); } // 言語切り替え function switchLang(lang) { if (lang === currentLang) return; currentLang = lang; map.setStyle(STYLES[lang]); updateUrl(lang); updateButtons(); } // ボタンのクリックイベント document.querySelectorAll('.lang-btn').forEach(btn => { btn.addEventListener('click', () => switchLang(btn.dataset.lang)); }); // ブラウザの戻る/進むに対応 window.addEventListener('popstate', () => { const lang = getLangFromUrl(); if (lang !== currentLang) { currentLang = lang; map.setStyle(STYLES[lang]); updateButtons(); } }); // 初期状態を設定 updateButtons(); </script> </body> </html> 使い方 HTMLファイルをWebサーバーで配信(ローカルファイルでは動作しません) アクセス例: index.html → 日本語(デフォルト) index.html?lang=en → 英語 index.html?lang=ja-Hira → ひらがな ポイント GETパラメータの取得 ...

TEI ODDによるIIIF対応ファクシミリ記述の制約設計

TEI ODDによるIIIF対応ファクシミリ記述の制約設計

はじめに TEI(Text Encoding Initiative)でデジタル画像のメタデータを記述する際、facsimile要素を使用します。特にIIIF(International Image Interoperability Framework)対応のデジタルアーカイブでは、マニフェストやキャンバス、Image APIへの参照を適切に記述することが重要です。 本記事では、ODD(One Document Does it all)を使用して、ファクシミリ記述に必要な制約をスキーマとして定義する方法を紹介します。 準拠するガイドライン 本ODDは、日本語TEIガイドラインで紹介されている「IIIF画像とのリンク」仕様をベースにしています: IIIF画像とのリンク(2024年度版)- TEI-EAJ このガイドラインに準拠したデータを作成することで、TEI Viewer for EAJでの画像表示が可能になります。TEI Viewerは、TEIテキストとIIIF画像を連携して表示できるビューアであり、facsimile要素の情報を利用してテキストと画像の対応付けを行います。 設計目標 以下の要件を満たすスキーマを設計しました: 必須情報の明確化 : 画像の座標情報や識別子など、最低限必要な情報を必須属性として定義 IIIF対応 : マニフェスト、キャンバス、Image APIへの参照をオプショナルに記述可能 再利用性 : 独立したODDファイルとして、複数プロジェクトで共有可能 ビューア互換性 : TEI Viewer for EAJでの表示に必要な情報を確実に記録 最小限の記述例 <facsimile sameAs="https://example.org/iiif/manifest.json"> <surface ulx="0" uly="0" lrx="5600" lry="4000" xml:id="p1"> <graphic sameAs="https://example.org/image/001.tif"/> </surface> </facsimile> 完全な記述例(IIIF参照を含む) <facsimile sameAs="https://example.org/iiif/manifest.json"> <surface ulx="0" uly="0" lrx="5600" lry="4000" sameAs="https://example.org/canvas/p1" xml:id="p1"> <graphic url="https://example.org/image/001.tif/full/full/0/default.jpg" sameAs="https://example.org/image/001.tif"/> </surface> </facsimile> ODD定義の解説 1. facsimile要素 <elementSpec ident="facsimile" mode="change"> <desc>ファクシミリ画像情報。IIIFマニフェストへの参照を含む。</desc> <classes mode="replace"/> <content> <elementRef key="surface" minOccurs="1" maxOccurs="unbounded"/> </content> <attList mode="replace"> <attDef ident="sameAs" mode="replace" usage="opt"> <desc>IIIFマニフェストへの参照URL</desc> <datatype> <dataRef key="teidata.pointer"/> </datatype> </attDef> </attList> </elementSpec> ポイント : ...

ODD Chain チュートリアル

ODD Chain チュートリアル

TEI ODDの「チェーン」機能を使ってスキーマをカスタマイズする方法を学ぶチュートリアルです。 ODD Chainとは ODD chainには2つの方式があります: 1. 継承型(縦のチェーン) source属性で親ODDを参照し、カスタマイズを継承します。 TEI_all → ベースODD → 派生ODD → さらなる派生... 2. 組み合わせ型(横のチェーン) specGrpとspecGrpRefを使って、複数のODDを統合します。 ヘッダー用ODD ─┬─→ 統合されたスキーマ 本文用ODD ─────┘ フォルダ構成 tutorials/ ├── 01-inheritance/ # 継承型の例 │ ├── base.odd # ベースとなるODD │ └── derived.odd # base.oddを継承する派生ODD ├── 02-chain/ # 組み合わせ型の例 │ ├── header-specs.odd # ヘッダー関連のカスタマイズ │ ├── text-specs.odd # 本文関連のカスタマイズ │ ├── main.odd # 統合用メインODD │ └── merge-specs.xsl # specGrpRefを展開するXSLT ├── output/ # 生成されたファイル │ ├── base.rng # 01のベースODDから生成 │ ├── base.html # 同上のHTMLドキュメント │ ├── derived.rng # 01の派生ODDから生成 │ ├── derived.html # 同上のHTMLドキュメント │ ├── combined.rng # 02の統合ODDから生成 │ ├── combined.html # 同上のHTMLドキュメント │ └── intermediate/ # 中間ファイル │ ├── base.compiled.odd │ ├── derived.compiled.odd │ ├── combined.merged.odd │ └── combined.compiled.odd ├── build.sh # ビルドスクリプト └── README.md # このファイル 前提条件 Saxon(XSLT 2.0プロセッサ) TEI Stylesheets(../scripts/Stylesheetsにインストール済み) ビルド方法 cd tutorials ./build.sh 生成されるファイル ソースODD RNG HTML 01-inheritance/base.odd output/base.rng output/base.html 01-inheritance/derived.odd output/derived.rng output/derived.html 02-chain/main.odd(統合後) output/combined.rng output/combined.html 各ファイルの説明 01-inheritance(継承型) base.odd ベースとなるODD。最小限のモジュールと基本的なカスタマイズを含む。 ...

TEI古典籍ビューワをカスタマイズして判読不能箇所(gap)を表示する

TEI古典籍ビューワをカスタマイズして判読不能箇所(gap)を表示する

はじめに 東アジアの古典籍をデジタル化する際、TEI(Text Encoding Initiative)ガイドラインに準拠したXMLでマークアップすることが一般的になっています。一般財団法人人文情報学研究所が開発した「TEI古典籍ビューワ」は、このようなTEI/XMLファイルをブラウザで簡単に表示できる便利なツールです。 公式サイト: https://tei.dhii.jp/teiviewer4eaj Web版: https://candra.dhii.jp/nagasaki/tei/tei_viewer/ 今回、このビューワをカスタマイズして、判読不能箇所を示す<gap>タグの表示に対応しました。本記事では、そのカスタマイズ方法を紹介します。 デモ: https://nakamura196.github.io/custom-tei-viewer/?file=sample_gap.xml&height=1800 課題:gapタグが表示されない 古典籍のデジタル化において、虫損や破損などにより判読できない箇所は<gap>タグでマークアップします。 <gap reason="illegible" quantity="2" unit="character"/> しかし、標準のTEI古典籍ビューワでは、このタグが適切に表示されません。そこで、判読不能な文字数分の黒四角(■)を表示し、マウスホバーで理由を確認できるようにカスタマイズしました。 カスタマイズの方針 TEI古典籍ビューワは以下のファイル構成になっています。 ├── index.html ├── app.min.js ← ビューワ本体(minify済み) ├── app.min.css ├── app_conf.js ← 設定ファイル └── lib/ ← 依存ライブラリ app.min.jsを直接編集すると、本体のアップデート時に変更が失われてしまいます。そこで、app_conf.jsのみを編集することで、本体との互換性を保ちながらカスタマイズを実現しました。 実装方法 1. MutationObserverによるDOM監視 TEI古典籍ビューワはXMLをパースしてDOMに変換します。この変換後のタイミングで<gap>タグを処理するため、MutationObserverを使用してDOM変更を監視します。 // MutationObserverでDOM変更を監視 const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { processGapElements(node); } }); }); }); // body_resultの監視を開始 document.addEventListener('DOMContentLoaded', () => { const bodyResult = document.getElementById('body_result'); if (bodyResult) { observer.observe(bodyResult, { childList: true, subtree: true }); } }); 2. gapタグの処理 <gap>タグを検出したら、quantity属性の値に応じて黒四角を表示し、reason属性をツールチップとして設定します。 function processGapElements(container) { const gaps = container.querySelectorAll('gap, .gap, [data-original-tag-name="gap"]'); gaps.forEach(gap => { // 既に処理済みの場合はスキップ if (gap.dataset.gapProcessed) return; gap.dataset.gapProcessed = 'true'; // 属性から値を取得 const quantity = parseInt(gap.getAttribute('quantity') || '1', 10); const reason = gap.getAttribute('reason') || ''; // ■をquantity分だけ生成 const placeholder = '■'.repeat(quantity); gap.textContent = placeholder; // ツールチップの設定(日本語化) const reasonMap = { 'illegible': '判読不能', 'damage': '破損', 'worm': '虫損', 'omitted': '省略', 'cancelled': '抹消', 'lost': '欠損' }; const reasonText = reasonMap[reason] || reason; if (reasonText) { gap.setAttribute('title', reasonText); } gap.style.color = '#333'; gap.style.cursor = 'help'; }); } ポイント:属性の取得方法 TEI古典籍ビューワがXMLをHTMLに変換する際、属性の扱いは要素によって異なります。<gap>タグの場合、XML属性がそのまま保持されるため、getAttribute()で直接取得できます。 ...

Mirador 4で任意の領域をハイライト表示する方法

Mirador 4で任意の領域をハイライト表示する方法

はじめに IIIFビューアのMiradorには検索機能があり、IIIF Search APIに対応したマニフェストでは検索結果をハイライト表示できます。しかし、Search APIに非対応のマニフェストでも、任意の領域をハイライト表示したいケースがあります。 本記事では、Miradorの内部APIを利用して、外部データソースからのアノテーション情報を基にハイライト表示を実現する方法を紹介します。 デモ Highlight Generator Form - フォームからハイライトを生成 ユースケース 独自のOCRシステムで抽出したテキスト領域のハイライト 機械学習で検出したオブジェクトの領域表示 外部データベースに保存されたアノテーションの可視化 Search API非対応のIIIFサーバーでの検索結果表示 実装方法 基本的な仕組み Miradorは内部でReduxを使用しており、receiveSearchアクションを通じて検索結果を登録できます。このアクションにIIIF Search API形式のJSONを渡すことで、任意のデータソースからのハイライトを表示できます。 必要な情報 ハイライトを表示するために必要な情報は以下の3つです: キャンバスURI - ハイライトを表示するページのURI 座標(xywh) - ハイライト領域の位置とサイズ(x, y, width, height) テキスト - ハイライトに関連付けるテキスト(検索パネルに表示される) サンプルコード 以下は、国立国会図書館デジタルコレクションの源氏物語で「いつれの御時にか…」の冒頭部分をハイライト表示するサンプルです。 <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Mirador Custom Highlight Sample</title> <style> body { margin: 0; padding: 0; } #mirador-viewer { width: 100%; height: 100vh; } </style> </head> <body> <div id="mirador-viewer"></div> <script src="https://unpkg.com/mirador@4.0.0-alpha.15/dist/mirador.min.js"></script> <script> // 設定パラメータ const config = { manifestUrl: 'https://dl.ndl.go.jp/api/iiif/3437686/manifest.json', canvasId: 'https://dl.ndl.go.jp/api/iiif/3437686/canvas/22', highlights: [ { xywh: '3095,694,97,2051', text: 'いつれの御時にか女御更衣あまたさふらひ給けるなかにいとやむことなきゝは', }, ], }; // Miradorを初期化 const miradorViewer = Mirador.viewer({ id: 'mirador-viewer', selectedTheme: 'light', language: 'ja', windows: [{ id: 'window-1', manifestId: config.manifestUrl, canvasId: config.canvasId, thumbnailNavigationPosition: 'far-right', }], window: { allowFullscreen: true, allowClose: false, allowMaximize: false, sideBarOpen: true, }, workspaceControlPanel: { enabled: false, }, }); // ハイライトを追加する関数 function addHighlights(viewer, canvasId, highlights) { // IIIF Search API形式のレスポンスを構築 const searchResponse = { '@context': 'http://iiif.io/api/search/1/context.json', '@id': canvasId + '/search', '@type': 'sc:AnnotationList', within: { '@type': 'sc:Layer', total: highlights.length, }, resources: highlights.map((highlight, index) => ({ '@id': canvasId + '/highlight-' + index, '@type': 'oa:Annotation', motivation: 'sc:painting', resource: { '@type': 'cnt:ContentAsText', chars: highlight.text, }, on: canvasId + '#xywh=' + highlight.xywh, })), }; // 検索パネルを右側に追加 const addAction = Mirador.addCompanionWindow('window-1', { content: 'search', position: 'right', }); viewer.store.dispatch(addAction); // companionWindowIdを取得 const state = viewer.store.getState(); const searchCompanionWindowId = Object.keys(state.companionWindows).find( id => state.companionWindows[id].content === 'search' ); if (searchCompanionWindowId) { // 検索結果を登録 const searchAction = Mirador.receiveSearch( 'window-1', searchCompanionWindowId, canvasId + '/search', searchResponse ); viewer.store.dispatch(searchAction); } } // マニフェストの読み込み完了を監視してハイライトを追加 let highlightAdded = false; const unsubscribe = miradorViewer.store.subscribe(() => { if (highlightAdded) return; const state = miradorViewer.store.getState(); const manifests = state.manifests || {}; const manifest = manifests[config.manifestUrl]; // マニフェストが読み込み完了したらハイライトを追加 if (manifest && !manifest.isFetching && manifest.json) { highlightAdded = true; unsubscribe(); addHighlights(miradorViewer, config.canvasId, config.highlights); } }); </script> </body> </html> コードの解説 1. 設定パラメータ const config = { manifestUrl: 'https://dl.ndl.go.jp/api/iiif/3437686/manifest.json', canvasId: 'https://dl.ndl.go.jp/api/iiif/3437686/canvas/22', highlights: [ { xywh: '3095,694,97,2051', text: 'いつれの御時にか...', }, ], }; manifestUrl: IIIFマニフェストのURL canvasId: ハイライトを表示するキャンバスのURI highlights: ハイライト情報の配列。複数のハイライトを追加可能 2. IIIF Search API形式のレスポンス構築 const searchResponse = { '@context': 'http://iiif.io/api/search/1/context.json', '@type': 'sc:AnnotationList', resources: highlights.map((highlight, index) => ({ '@type': 'oa:Annotation', motivation: 'sc:painting', resource: { '@type': 'cnt:ContentAsText', chars: highlight.text, }, on: canvasId + '#xywh=' + highlight.xywh, })), }; ポイントは on プロパティで、キャンバスURI#xywh=x,y,width,height の形式でハイライト領域を指定します。 ...

Mirador 4でキャンバス指定と検索語ハイライトを同時に実現する方法

Mirador 4でキャンバス指定と検索語ハイライトを同時に実現する方法

はじめに IIIF(International Image Interoperability Framework)ビューアとして広く使われているMiradorで、以下の要件を満たす実装を行いました: URLパラメータで指定したキャンバス(ページ)を初期表示する 指定したキャンバス内の検索語をハイライト表示する 本記事では、この要件を実現するためのアプローチと実装方法を共有します。 アプローチの検討 defaultSearchQueryオプション Mirador 4では、ウィンドウ設定に defaultSearchQuery オプションを指定することで、初期化時に自動的に検索を実行できます: const miradorViewer = Mirador.viewer({ windows: [{ manifestId: manifestUrl, canvasId: canvasId, defaultSearchQuery: '検索語', }], }); このオプションは検索を自動実行する便利な機能ですが、今回の要件では以下の点を考慮する必要がありました: ページ遷移の制御 - 検索実行時、最初のヒットのページに自動遷移する仕様のため、canvasId で指定したページに留まりたい場合は追加の制御が必要 検索完了タイミングの把握 - 非同期で検索が実行されるため、完了後に処理を行いたい場合はRedux状態の監視が必要 より直接的なアプローチ 検討の結果、receiveSearch アクションを直接使用するアプローチを採用しました。このアプローチでは: Search APIを直接呼び出して、指定キャンバスに該当するヒットのみを取得 IIIF Search API形式のレスポンスを構築 receiveSearch アクションで検索結果としてMiradorに登録 これにより、指定キャンバスの表示を維持しながら、Miradorの検索ハイライト機能をそのまま活用できます。 実装 1. Search APIからヒットを取得する関数 const getSearchHitsForCanvas = async ( manifestUrl: string, canvasId: string, query: string ): Promise<{ id: string; chars: string; xywh: string }[]> => { try { // manifestからsearch serviceのURLを取得 const manifestResponse = await fetch(manifestUrl); if (!manifestResponse.ok) return []; const manifest = await manifestResponse.json(); // IIIF Search API serviceを探す const services = manifest.service || []; const searchService = (Array.isArray(services) ? services : [services]).find( (s: { profile?: string }) => s.profile?.includes('search') ); if (!searchService) return []; const searchBaseUrl = searchService['@id'] || searchService.id; const searchUrl = `${searchBaseUrl}?q=${encodeURIComponent(query)}`; const response = await fetch(searchUrl); if (!response.ok) return []; const data = await response.json(); const resources = data.resources || []; // 指定キャンバスに該当するヒットのみを抽出 const hits: { id: string; chars: string; xywh: string }[] = []; for (const resource of resources) { const [resourceCanvas, fragment] = resource.on.split('#'); if (resourceCanvas === canvasId && fragment) { hits.push({ id: resource['@id'] || resource.id, chars: resource.resource?.chars || query, xywh: fragment.replace('xywh=', ''), }); } } return hits; } catch (error) { console.error('Failed to fetch search hits:', error); return []; } }; この実装では、manifestのserviceプロパティからIIIF Search APIのエンドポイントを動的に取得しています。これにより、任意のIIIF準拠サーバーで動作します。 ...

RAWGraphs 2.0 の日本語化

RAWGraphs 2.0 の日本語化

はじめに データ可視化ツール RAWGraphs を日本語化し、公開しました。 https://rawgraphs-ja.vercel.app/ RAWGraphsは、複雑なデータを美しいビジュアライゼーションに変換できるオープンソースのWebアプリケーションです。コーディング不要で、CSVやJSONデータをドラッグ&ドロップするだけで、様々なチャートを作成できます。 RAWGraphsとは RAWGraphsは、イタリアのDensityDesign Research Labが開発したデータ可視化ツールです。 https://www.rawgraphs.io/ 主な特徴: コーディング不要 : ブラウザ上でデータをペーストするだけ 多様なチャート : Alluvial Diagram、Treemap、Voronoi Tessellationなど30種類以上 SVGエクスポート : 作成したチャートをSVGやPNG形式でダウンロード可能 プライバシー保護 : データはサーバーに送信されず、ブラウザ内で処理 使用ライブラリ { "i18next": "^21.10.0", "react-i18next": "^11.18.6" } ! 最新版のi18nextではなく、互換性のある旧バージョンを使用しています。RAWGraphsのビルド環境(react-scripts 4.x)がオプショナルチェーニング(?.)構文を完全にサポートしていないためです。 実装手順 1. i18nの初期化 src/i18n.js を作成し、多言語対応の基盤を構築しました。 import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import translationEN from './locales/en/translation.json' import translationJA from './locales/ja/translation.json' const resources = { en: { translation: translationEN }, ja: { translation: translationJA } } const savedLanguage = localStorage.getItem('i18nextLng') || 'ja' i18n .use(initReactI18next) .init({ resources, lng: savedLanguage, fallbackLng: 'en', interpolation: { escapeValue: false } }) i18n.on('languageChanged', (lng) => { localStorage.setItem('i18nextLng', lng) }) export default i18n 2. 翻訳ファイルの構造 src/locales/ ├── en/ │ └── translation.json └── ja/ └── translation.json 翻訳ファイルは、セクションごとに整理しました: ...

Next.js + next-intl での言語切り替え実装ガイド

Next.js + next-intl での言語切り替え実装ガイド

Next.js App Router と next-intl を使用した多言語対応アプリケーションで、リロードなしの言語切り替えを実装する方法をまとめます。 環境 Next.js 16 (App Router) next-intl TypeScript 設定概要 localePrefix: ‘as-needed’ とは next-intl の localePrefix: 'as-needed' 設定を使用すると、デフォルト言語ではURLにプレフィックスが付かず、その他の言語のみプレフィックスが付きます。 例(デフォルト言語が日本語の場合): 日本語: /, /gallery, /viewer 英語: /en, /en/gallery, /en/viewer 実装手順 1. Middleware設定(重要) next-intl は middleware を使用してロケールのルーティングを処理します。静的ファイルが middleware によってリダイレクトされないよう、matcher の設定が重要です。 // middleware.ts import createMiddleware from 'next-intl/middleware' import { routing } from './src/i18n/routing' export default createMiddleware(routing) export const config = { // Skip middleware for static files and API routes matcher: ['/((?!api|_next|.*\\..*).*)'] } 注意 : .*\\..* は「ドットを含むパス」を除外します。これにより /og-image.png などの静的ファイルがmiddlewareをスキップし、正常にアクセスできるようになります。 2. ルーティング設定 // src/i18n/routing.ts import { defineRouting } from 'next-intl/routing' export const routing = defineRouting({ locales: ['ja', 'en'], defaultLocale: 'ja', localePrefix: 'as-needed' }) 3. ナビゲーションヘルパーの作成 next-intl が提供する createNavigation を使用して、ロケール対応のナビゲーションヘルパーを作成します。 ...

vipsによるピラミダルタイルTIFF作成と圧縮方式の比較

vipsによるピラミダルタイルTIFF作成と圧縮方式の比較

はじめに 高解像度画像をWeb上で快適に閲覧するためには、ピラミダル構造(複数解像度)とタイル分割が不可欠です。本記事では、vipsを使用してJPEG2000画像からピラミダルタイルTIFFを作成し、各圧縮方式のファイルサイズを比較検証しました。 検証環境 vips 8.17.3 macOS (darwin) 元画像: 764029-1.jp2 (274MB) 出典: 国立公文書館デジタルアーカイブ vipsコマンド JPEG圧縮(非可逆) # 品質100(ほぼ無劣化) vips tiffsave input.jp2 output_q100.tif --tile --pyramid --compression=jpeg --Q=100 # 品質75(バランス型) vips tiffsave input.jp2 output_q75.tif --tile --pyramid --compression=jpeg --Q=75 # 品質25(高圧縮) vips tiffsave input.jp2 output_q25.tif --tile --pyramid --compression=jpeg --Q=25 ロスレス圧縮 # Deflate圧縮(zlib) vips tiffsave input.jp2 output_deflate.tif --tile --pyramid --compression=deflate # LZW圧縮 vips tiffsave input.jp2 output_lzw.tif --tile --pyramid --compression=lzw # 無圧縮(4GB超の場合はBigTIFF形式が必要) vips tiffsave input.jp2 output_none.tif --tile --pyramid --compression=none --bigtiff 検証結果 ファイル 圧縮方式 サイズ 元ファイル比 備考 元ファイル JPEG2000 274MB - 入力 q25.tif JPEG Q=25 57MB 0.21x 非可逆・高圧縮 q75.tif JPEG Q=75 167MB 0.61x 非可逆・バランス q100.tif JPEG Q=100 2.4GB 8.8x 非可逆・高品質 deflate.tif Deflate 2.8GB 10.2x ロスレス lzw.tif LZW 3.2GB 11.7x ロスレス none.tif 無圧縮 4.3GB 15.7x ロスレス 画質比較 JPEG圧縮の品質による違いを視覚的に比較しました(左から Q=25, Q=75, Q=100)。 ...

アノテーション表示のパフォーマンス改善

アノテーション表示のパフォーマンス改善

概要 3Dビューワーでアノテーションが多数ある場合、背面判定(Raycast)処理がパフォーマンスのボトルネックになります。本ドキュメントでは、採用した改善手法について説明します。 問題 アノテーションの背面判定には、各アノテーションに対してRaycast(光線とメッシュの衝突判定)を実行する必要があります。この処理は以下の理由で重くなります: メッシュの全頂点との衝突判定が必要 アノテーション数に比例して計算量が増加 毎フレーム実行すると60FPSの維持が困難 解決策: Idle時のみRaycast実行 カメラが停止した時のみRaycast処理を実行 する方式を採用しました。 動作フロー カメラ移動中 → Raycast処理をスキップ(軽量) ↓ カメラ停止検出 ↓ 30フレーム待機(約0.5秒、安定化) ↓ 1回だけRaycast実行 ↓ 次にカメラが動くまで再計算しない 実装詳細 // パフォーマンス設定 const CAMERA_MOVE_THRESHOLD = 0.01; // カメラ移動の閾値(スクロール時の微小な動きを無視) const IDLE_FRAMES_BEFORE_RAYCAST = 30; // 停止後このフレーム数待ってからRaycast(約0.5秒 @ 60fps) useFrame(() => { // カメラの移動量をチェック const cameraMoved = camera.position.distanceTo(prevCameraPosition) > CAMERA_MOVE_THRESHOLD; prevCameraPosition.copy(camera.position); if (cameraMoved) { // カメラが動いている間はカウンタをリセット idleFrameCountRef.current = 0; needsRaycastRef.current = true; return; // Raycast処理をスキップ } // カメラが停止している idleFrameCountRef.current++; // 停止後、一定フレーム待ってからRaycast実行(1回のみ) if (!needsRaycastRef.current) return; if (idleFrameCountRef.current < IDLE_FRAMES_BEFORE_RAYCAST) return; // Raycast実行(1回のみ) needsRaycastRef.current = false; // ... Raycast処理 ... }); 2段階の判定処理 Raycast処理自体も最適化しています: ...

傾いた文字のアノテーションとIIIF画像切り出し

傾いた文字のアノテーションとIIIF画像切り出し

はじめに 古地図や古文書には、様々な方向に傾いた文字が含まれています。本ツールでは、多角形(ポリゴン)アノテーションを使用して傾いた文字を正確にマークアップし、その傾き情報を活用してIIIF Image APIで回転補正された画像を取得できます。 傾き計算の仕組み 頂点の順序ルール 多角形アノテーションを作成する際、以下の順序で頂点を指定します: 左上 → 2. 左下 → 3. 右下 → 4. 右上 この反時計回りの順序を守ることで、傾き角度を一意に計算できます。 左上(1) ─────────── 右上(4) │ │ │ 文字領域 │ │ │ 左下(2) ─────────── 右下(3) 以下のデモ動画も参考にしてください。 https://youtu.be/P9srTeynXuk?si=mJO1yu3IhR0QFV-2 角度の計算方法 上辺(左上→右上)のベクトルから傾き角度を計算します: // SVGパスから頂点を抽出 // M x1,y1 L x2,y2 L x3,y3 L x4,y4 Z // 頂点0(左上), 頂点1(左下), 頂点2(右下), 頂点3(右上) const dx = 右上.x - 左上.x; // 頂点3.x - 頂点0.x const dy = 右上.y - 左上.y; // 頂点3.y - 頂点0.y const angle = Math.atan2(dy, dx) * (180 / Math.PI); // IIIFの回転パラメータ(時計回りが正) let iiifRotation = -angle; if (iiifRotation 0) iiifRotation += 360; 実際の使用例 例1: 横書きでほぼ水平な文字 アノテーションデータ: ...

Elasticsearch/OpenSearch クラスタ間のデータ移行ガイド

Elasticsearch/OpenSearch クラスタ間のデータ移行ガイド

Amazon Elasticsearch Service から別の OpenSearch クラスタへデータを移行する方法を解説します。本記事では、Scroll API と Bulk API を使用したシンプルかつ確実な移行手法を紹介します。 背景 クラウドサービスの移行やコスト最適化のため、Elasticsearch/OpenSearch クラスタ間でデータを移行する必要が生じることがあります。今回は以下の環境間での移行を行いました。 移行元 : Amazon Elasticsearch Service (AWS) 移行先 : セルフホスト OpenSearch 移行の流れ 移行元・移行先のインデックス確認 マッピング情報の取得と調整 移行先にインデックスを作成 Scroll API + Bulk API でデータ移行 移行結果の確認 事前準備:インデックスの確認 まず、移行元と移行先のインデックス一覧を確認します。 # 移行元のインデックス一覧 curl -u "user:password" "https://source-cluster/_cat/indices?v&s=index" # 移行先のインデックス一覧 curl -u "user:password" "https://dest-cluster/_cat/indices?v&s=index" Step 1: マッピング情報の取得 移行元からマッピング情報を取得します。 curl -s -u "user:password" \ "https://source-cluster/index_name/_mapping" \ > mappings.json Step 2: マッピングの調整 移行先の環境によっては、カスタムアナライザーが利用できない場合があります。例えば、日本語形態素解析用の kuromoji プラグインがインストールされていない場合、アナライザー設定を除去する必要があります。 def remove_analyzer(obj): """マッピングからanalyzer設定を再帰的に削除""" if isinstance(obj, dict): if 'analyzer' in obj: del obj['analyzer'] for key, value in obj.items(): remove_analyzer(value) elif isinstance(obj, list): for item in obj: remove_analyzer(item) return obj Step 3: 移行先にインデックスを作成 調整したマッピングを使用してインデックスを作成します。 ...

Docker + GitHub Actions デプロイ設定

Docker + GitHub Actions デプロイ設定

このドキュメントでは、Docker コンテナを GitHub Actions で自動デプロイする設定手順を説明します。 目次 Docker 設定 GitHub Actions 設定 サーバー側の設定 トラブルシューティング Docker 設定 Dockerfile(静的サイト + nginx) 静的 HTML を生成し、nginx で配信します。 FROM node:22-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run generate # 静的ファイル配信用のnginx FROM nginx:alpine # Nuxt 3 の場合: .output/public # Nuxt 2 の場合: dist COPY --from=builder /app/.output/public /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] nginx.conf(SPA 用設定) SPA では動的ルート(/item/:id など)を index.html にフォールバックさせる必要があります。 ...

360度動画・写真から歪みのないサムネイル画像を作成する方法

360度動画・写真から歪みのないサムネイル画像を作成する方法

Insta360などで撮影した360度コンテンツ(equirectangular形式)から、自然な見た目のサムネイル画像を作成する方法を紹介します。 課題:そのままリサイズすると歪む 360度動画や写真はequirectangular(正距円筒図法)形式で保存されています。この形式は球体を平面に展開したもので、特に上下の端に近いほど横方向に引き伸ばされています。 そのまま単純にリサイズしてサムネイルを作成すると、歪んだ不自然な画像になってしまいます。 # 単純なリサイズ(歪んだサムネイルになる) ffmpeg -i 360video.mp4 -ss 00:00:05 -vframes 1 -vf "scale=640:-1" thumb.jpg 解決策:v360フィルターでflat projectionに変換 ffmpegのv360フィルターを使用して、equirectangular形式からflat(rectilinear/透視投影)形式に変換することで、人間の目で見たような自然な画像を切り出せます。 使用ツール ffmpeg を使用します。macOSの場合、Homebrewでインストールできます。 brew install ffmpeg v360フィルターが含まれているか確認: ffmpeg -filters 2>/dev/null | grep v360 # 出力: .SC v360 V->V Convert 360 projection of video. 基本コマンド 動画からサムネイル作成 ffmpeg -i 360video.mp4 -ss 00:00:05 -vframes 1 \ -vf "v360=e:flat:h_fov=120:v_fov=90:yaw=0:pitch=0,scale=640:-1" \ thumbnail.jpg -y 写真からサムネイル作成 ffmpeg -i 360photo.jpg \ -vf "v360=e:flat:h_fov=120:v_fov=90:yaw=0:pitch=0,scale=640:-1" \ thumbnail.jpg -y v360フィルターのパラメータ解説 パラメータ 説明 e 入力形式:equirectangular flat 出力形式:flat(透視投影) h_fov=120 水平視野角(度) v_fov=90 垂直視野角(度) yaw=0 水平方向の回転(-180〜180) pitch=0 垂直方向の回転(-90〜90) 視野角の調整 用途に応じて視野角を調整できます。 ...