PDFium 컴포넌트 뷰어에서 인쇄 작업 도중 취소를 클릭해도 때로는 아무 일도 일어나지 않았다: 루프는 남은 모든 페이지와 매수를 계속 렌더링했고, 작업은 그래도 프린터에 도달했다. 원인은 Free Pascal의 for 루프가 루프 진입 시 상한을 한 번만 고정한다는 데 있었다. 그래서 CopyCount나 ToPage 뒤에 있는 변수를 루프 도중에 0으로 만들어도 이미 실행 중인 것에는 아무것도 바뀌지 않았다. 이 for 루프 상한 버그는 다른 네 가지 크로스 컴파일러 함정을 만들어낸 것과 같은 PDFiumPas Delphi/FPC 호환성 감사에서 나온 다섯 번째 함정이며, 그 넷과 달리 이것은 완전히 뷰어의 인쇄 루틴 안에 산다 — 긴 작업 동안 취소를 누르고 있던 테스터가 있고 나서야 프린터가 절대 멈추지 않는다는 것을 실제로 알아차렸다
취소를 클릭한 뒤에도 인쇄 작업이 왜 계속 진행되는가?
인쇄 작업이 계속 진행된 이유는 취소 확인이 루프 상한을 공급하는 변수만 초기화했을 뿐, 각 루프가 시작될 때 Free Pascal이 이미 고정해 둔 루프 자체는 초기화하지 않았기 때문이다. PDFViewer와 MultiPageViewer 데모에서 인쇄 버튼 뒤에 있는 SpeedButtonPrintClick 핸들러는 모든 작업을 세 개의 중첩된 루프로 만든다: 페이지 매김된 복사 세트에 대한 바깥 루프, 페이지에 대한 중간 루프, 같은 페이지의 페이지 매김되지 않은 매수에 대한 안쪽 루프다. PrintDialog.Collate는 CollateCopyCount와 CopyCount 중 어느 카운터가 실제로 요청된 매수를 담는지 결정하고, 다른 하나는 1로 유지된다. 취소는 세 수준 모두에 동시에 닿아야 하며, 이 코드의 첫 버전은 Cancel이 true가 되는 순간 상한 변수 자체를 초기화함으로써 이를 시도했다
for CollateCopy:= 1 to CollateCopyCount do
for Page:= FromPage to ToPage do
for Copy:= 1 to CopyCount do
begin
// ... render the page and send it to the printer ...
Application.ProcessMessages;
if Cancel then
begin
CollateCopyCount:= 0;
ToPage:= 0;
CopyCount:= 0;
end;
end;
Printer.EndDoc; // runs whether or not Cancel fired
그 의도는 충분히 명확하게 읽힌다: 남은 페이지 매김, 페이지, 매수를 정의하는 카운터가 모두 0으로 떨어지면, 루프는 할 일이 바닥나 스스로 빠져나가야 한다는 것이다. 그런 다음 Printer.EndDoc은 루프가 어떻게 끝났든 상관없이 그 뒤에 무조건 실행되었고, 그래서 사용자가 멈췄다고 믿었던 작업조차 클릭이 인지되기 전에 렌더링된 모든 페이지와 함께 여전히 스풀러에 제출되었다
for 루프가 시작될 때 Free Pascal이 고정하는 것
Free Pascal은 for 루프의 종료값을 루프가 시작되는 순간 정확히 한 번 평가하며, 그 루프의 생애 동안 다시는 평가하지 않는다. for Page := FromPage to ToPage do는 몇 번의 반복을 실행할지 계산하기 위해 ToPage를 한 번 읽으며, 그 이후로 루프는 ToPage라는 이름의 변수에 더 이상 관심이 없고 오직 이미 캡처해 둔 반복 횟수에만 관심이 있다. 루프 본문 안에서 ToPage := 0을 설정하는 것은 실행 중인 루프가 더 이상 참조하지 않는 변수를 바꾸는 것이며, 이것이 정확히 Cancel이 true인데도 프린터가 몇 번 더, 때로는 남은 것 전부에 대해 계속 페이지를 받았던 이유다
이 패턴은 C나 C++에서 옮겨오기에 완전히 합리적인 습관이며, PDFium 컴포넌트의 C++Builder 뷰어 데모는 이 비교를 직접적으로 만들 만큼 Pascal 것과 충분히 가깝게 대응한다. 그 for (Copy = 1; Copy <= CopyCount; Copy++)는 매 패스마다 CopyCount의 살아있는 값에 대해 Copy <= CopyCount를 다시 검사하므로, 거기서 카운터를 0으로 만드는 것은 다음 검사에서 진짜로 루프를 끝낸다. C++Builder 데모는 똑같이 카운터를 0으로 만드는 코드를 가지고 있었고 그것은 작동했으며, 이것이 정확히 같은 저장소 안 바로 옆에 있는 Pascal 빌드에서도 같은 발상을 재사용해도 안전해 보이게 만든 이유였다
Delphi 빌드는 왜 같은 버그를 겪지 않았는가?
Delphi PDFViewer 데모는 루프가 바뀐 상한을 알아차리는 것에 결코 의존하지 않았는데, 그 취소 경로가 비교가 아니라 예외를 통해 되감기 때문이다. 그 가장 안쪽 루프는 Cancel이 true가 되는 순간 RTL의 Abort 프로시저를 호출하며, 이는 세 개의 중첩된 for 루프 전체를 곧바로 통과해 전체 인쇄 블록을 감싸는 핸들러로 전파되는 조용한 EAbort를 일으킨다
Printer.BeginDoc;
try
for CollateCopy:= 1 to CollateCopyCount do
for Page:= FromPage to ToPage do
for Copy:= 1 to CopyCount do
begin
// ... render the page and send it to the printer ...
Application.ProcessMessages;
if Cancel then
Abort; // raises EAbort, unwinds all three loops at once
end;
Printer.EndDoc;
except
on E: EAbort do
Printer.Abort;
else
begin
Printer.Abort;
raise;
end;
end;
예외는 그것이 발생한 지점과 그것을 잡는 핸들러 사이에 for 루프가 몇 개나 끼어 있는지 신경 쓰지 않으며, 이것이 정확히 이 문제가 필요로 하는 속성이다. 이 견고함은 위에서 설명한 상한 고정 동작에 대한 의도적인 방어가 아니었다 — Delphi 데모의 작성자는 그저 다른 도구를 손에 쥐었을 뿐이다. 이 예외 기반 탈출구는 그럼에도 더 견고한 패턴으로 이름 붙일 가치가 있다: 나중에 네 번째 중첩 수준이 추가되어도 살아남는 반면, 수동으로 배치한 Break 문의 연쇄는 루프 중첩이 바뀔 때마다 기억하고 다시 추가해야 한다
수정: 모든 중첩 수준에서 Break하고, PrintSucceeded 플래그로 관문 세우기
PDFiumPas v2.27.0에 배포된 수정은 Lazarus와 C++Builder 뷰어 데모 안의 3중 루프 구조는 유지하되 모든 수준에서 취소를 명시적으로 만들고, 루프가 멈추는 것과 작업이 제출되는 것을 루프가 완전히 끝난 뒤에야 읽히는 플래그로 분리한다
Printer.BeginDoc;
try
Cancel:= False;
for CollateCopy:= 1 to CollateCopyCount do
begin
for Page:= FromPage to ToPage do
begin
for Copy:= 1 to CopyCount do
begin
// ... render the page and send it to the printer ...
Application.ProcessMessages;
if Cancel then
Break;
end;
if Cancel then
Break;
end;
if Cancel then
Break;
end;
PrintSucceeded:= not Cancel;
finally
if PrintSucceeded then
Printer.EndDoc
else
Printer.Abort;
end;
PrintSucceeded는 3중 중첩 루프가 빠져나온 직후, not Cancel 그 이상도 이하도 아닌 것으로부터 의도적으로 딱 한 번 계산된다. 루프 본문 안의 어느 것도 작업이 성공했는지를 스스로 결정하지 못한다 — 루프는 오직 두 가지 방식으로만 끝날 수 있다, 페이지 매김·페이지·매수가 바닥나거나, Cancel이 촉발하는 Break 체인에 부딪히거나다. 그리고 PrintSucceeded는 루프가 진행되는 동안 추적하는 대신 결과가 나온 뒤에 그것을 읽는다. 이런 식으로 계산하는 것이 finally 블록의 Printer.EndDoc과 Printer.Abort 사이의 선택을 신뢰할 수 있게 만든다: 이는 루프가 실제로 결말이 난 뒤에야 발동한다
여러분 자신의 취소 주도 인쇄 루프 감사하기
세 가지 확인 사항이 이 하나의 인쇄 루틴을 훨씬 넘어 적용된다. Pascal for 루프가 시작된 이후 바뀐 상한 변수를 알아차릴 것이라고 절대 가정하지 말라; 루프가 일찍 끝나야 한다면, 취소가 건너야 하는 모든 중첩 수준에서, 가장 안쪽뿐 아니라, Break로 직접 그렇게 말하라. 중첩이 깊어서 빠뜨린 Break가 그럴듯한 시나리오가 되는 순간부터는 예외처럼 구조적으로 되감는 탈출 메커니즘을 선호하라 — Delphi 데모의 Abort/EAbort 쌍은 이를 공짜로 얻었다. EndDoc 같은 커밋 단계는 루프 안이 아니라 루프 이후에만 엄격히 계산되는 플래그 뒤에 관문을 세워, 일찍 멈춘 작업이 끝난 작업으로 절대 오인되지 않도록 하라
이 루프를 고친 것과 같은 검토는 취소 버튼이 보이는 동안 아홉 개 뷰어 데모가 키보드를 다루는 방식도 조여냈다: 이제 Esc를 제외한 모든 키를 삼켜, 활성 인쇄나 검색 도중의 엉뚱한 Ctrl+P나 Ctrl+F가 그 위에 두 번째 것을 시작할 수 없게 되었다. 이 재진입 수정은 다른 메커니즘을 가진 다른 버그이지만, Cancel이 작업 도중 실제로 무엇을 하는지에 대한 같은 검토에서 나왔다. 두 수정 모두에서 챙길 가치가 있는 습관은 코딩 습관이라기보다 테스트 습관이다: 공유 인쇄 루틴이 실제로 배포되는 모든 컴파일러에서 취소를 실행해 보라. 카운터를 지우고 루프가 그것을 알아차릴 것으로 믿는 발상은 C++Builder 하에서의 테스트는 통과하고, 검증되지 않은 채 Free Pascal 빌드에 도달하고, 소스에서는 똑같이 읽히면서도, 누군가 취소를 충분히 오래 누르고 있으면서도 작업이 그래도 끝나는 것을 지켜보기 전까지는 아무도 보지 못하는 방식으로 실패할 수 있다. 이 취소 로직이 그 위에 자리하는 인쇄 설정에 대해서는, PDFium VCL 컴포넌트로 PDF 문서 인쇄하기에 관한 안내가 나머지를 다룬다
이 인쇄 취소 수정은 Delphi, C++Builder, FPC/Lazarus용 PDFium 컴포넌트의 일부로 배포되었으며, 이 컴포넌트는 모든 릴리스마다 세 컴파일러 전체에 걸쳐 같은 데모 스위트를 실행해 이런 종류의 공백이 지원 티켓이 아니라 빌드 매트릭스에서 잡히도록 한다