ページ選択欄に「1」と入力した利用者が期待するのは、先頭ページです。一方、処理側の配列では先頭がインデックス 0 かもしれません。この変換があいまいだと、正しく入力されても隣のページを選んでしまいます。ここでは、表示上の番号は1始まり、返り値は0始まりと決めておきます。
受け付ける書式を先に決める
例の parsePages が読むのは、ASCIIの正の整数と範囲を半角カンマで区切った文字列です。1-3,5 は [0,1,2,4] になります。4-2 のような逆順も受け付け、重複したページは最初に現れた位置だけを残します。トークンの前後の空白は許しますが、1 - 2 のように範囲の内側に入った空白は許しません。
日本語の環境では、IMEで入力した数字や区切り文字が全角になることがあります。この関数では、全角数字の 1 は null です。読点や全角カンマで区切った 1、2 や 1,2 も null になります。トークンを分けるのはASCIIの , だけだからです。数字の間に全角スペース(U+3000)が入った 1 2 も null です。一方、前後にある全角スペースは String.prototype.trim() で取り除かれるので、' 1,2 ' は [0,1] として読めます。
ほかに、空欄、末尾のカンマ、2junk、0、先頭ゼロ付きの 01、セミコロンも拒否します。これは教材用に狭く決めた方針であり、すべての製品に共通する書式ではありません。実際の画面で使うなら、受け付ける入力例を入力欄の近くに示しておくと、利用者が自分で直しやすくなります。
長さにも上限があります。文字列は120 UTF-16コード単位まで、トークンは20個まで、ページ数は1〜10,000、範囲を展開した回数は重複を含めて1,000までです。1-1000,1 は重複を除けば1,000ページですが、展開回数が上限を超えるので拒否されます。
export function parsePages(text, pageCount) {
if (typeof text !== 'string' || text.length > 120 ||
!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > 10000) return null;
const tokens = text.trim().split(',');
if (tokens.length > 20) return null;
const pages = [], seen = new Set();
let expanded = 0;
for (const token of tokens) {
const match = /^([1-9]\d*)(?:-([1-9]\d*))?$/.exec(token.trim());
if (!match) return null;
const from = Number(match[1]), to = Number(match[2] ?? match[1]);
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) ||
from > pageCount || to > pageCount) return null;
expanded += Math.abs(to - from) + 1;
if (expanded > 1000) return null;
const step = to >= from ? 1 : -1;
for (let page = from; page !== to + step; page += step) {
if (!seen.has(page)) { seen.add(page); pages.push(page - 1); }
}
}
return pages;
}
拒否の理由を画面でどう伝えるか
この関数は、不正な入力をすべて null で返します。呼び出し側はこの返り値だけでは「全角数字」「範囲外」「展開回数の超過」を区別できません。理由ごとにメッセージを出したいなら、その診断の仕組みを別に設計する必要があります。黙って読めた部分だけを採用する仕様にはしていません。
次のアサーションは、関数の後ろに貼り付けて同じ .mjs ファイルにまとめます。配列全体を比較するので、ページ番号の変換と、重複があるときの順序を確かめられます。
import assert from 'node:assert/strict';
const valid = [
['1-3,5',6,[0,1,2,4]],
['4-2,2,6',6,[3,2,1,5]],
[' 1 , 3-2 ',3,[0,2,1]],
['2,2',3,[1]],
['1-1000',1000,Array.from({length:1000},(_,i)=>i)]
];
for (const [text,count,expected] of valid) assert.deepEqual(parsePages(text,count),expected);
const invalid = [
['',3],['1,',3],['2junk',3],['0',3],['4',3],['01',3],['1',3],
['1;2',3],['1 - 2',3],['1-1001',1001],['1-1000,1',1000],
['1',0],['1',2.5],['1',10001],[null,3],['1'.repeat(121),3],
[Array(21).fill('1').join(','),3]
];
for (const [text,count] of invalid) assert.equal(parsePages(text,count),null);
console.log(JSON.stringify({valid:valid.length,rejected:invalid.length,boundary:'synthetic text and page indices only'}));
11月1日の保存済み Node v22.23.4 実行記録では、有効例5件と拒否例17件を扱い、スタブは終了コード1、実装は0で終了しました。対象は合成した文字列とインデックスだけです。PDFのデコード・書き込み、ZIP作成、ブラウザーや端末での動作、変換はテストしていません。
開示:GPT-6.1 Terra を使って Yan Wang / LensUp のために執筆しました。Yan Wang は LensUp の関係者です。本文は独立した編集AI(lensup 主编)が推敲し、Codex(AI)が事実確認と、記録済みの合成入力によるコード実行結果との照合を行いました。人間の編集者やオーナーによる確認・テストは行っていません。根拠は11月1日のソースと公開ページの確認、および上記の保存済み合成Node実行記録です。