bidi2pdf 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +61 -1
- data/README.md +47 -10
- data/docker/Dockerfile +11 -3
- data/docker/Dockerfile.chromedriver +4 -2
- data/docker/Dockerfile.slim +75 -0
- data/lib/bidi2pdf/bidi/browser_console_logger.rb +92 -0
- data/lib/bidi2pdf/bidi/browser_tab.rb +415 -39
- data/lib/bidi2pdf/bidi/client.rb +85 -23
- data/lib/bidi2pdf/bidi/command_manager.rb +46 -48
- data/lib/bidi2pdf/bidi/commands/base.rb +39 -1
- data/lib/bidi2pdf/bidi/commands/browser_remove_user_context.rb +27 -0
- data/lib/bidi2pdf/bidi/commands/browsing_context_print.rb +4 -0
- data/lib/bidi2pdf/bidi/commands/print_parameters_validator.rb +5 -0
- data/lib/bidi2pdf/bidi/commands.rb +1 -0
- data/lib/bidi2pdf/bidi/event_manager.rb +1 -1
- data/lib/bidi2pdf/bidi/interceptor.rb +1 -1
- data/lib/bidi2pdf/bidi/js_logger_helper.rb +16 -0
- data/lib/bidi2pdf/bidi/logger_events.rb +66 -0
- data/lib/bidi2pdf/bidi/network_event.rb +40 -7
- data/lib/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rb +110 -0
- data/lib/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rb +53 -0
- data/lib/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rb +125 -0
- data/lib/bidi2pdf/bidi/network_event_formatters.rb +11 -0
- data/lib/bidi2pdf/bidi/network_events.rb +46 -17
- data/lib/bidi2pdf/bidi/session.rb +120 -13
- data/lib/bidi2pdf/bidi/user_context.rb +62 -0
- data/lib/bidi2pdf/bidi/web_socket_dispatcher.rb +7 -7
- data/lib/bidi2pdf/chromedriver_manager.rb +48 -21
- data/lib/bidi2pdf/cli.rb +27 -3
- data/lib/bidi2pdf/dsl.rb +33 -0
- data/lib/bidi2pdf/launcher.rb +34 -2
- data/lib/bidi2pdf/notifications/event.rb +52 -0
- data/lib/bidi2pdf/notifications/instrumenter.rb +65 -0
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +136 -0
- data/lib/bidi2pdf/notifications.rb +78 -0
- data/lib/bidi2pdf/session_runner.rb +49 -7
- data/lib/bidi2pdf/verbose_logger.rb +79 -0
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +99 -5
- data/sig/bidi2pdf/bidi/client.rbs +1 -1
- metadata +45 -4
- data/lib/bidi2pdf/utils.rb +0 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 766b41f0ee642cd7316d0f72d8dd707b0f45aae4a315a46c2b27fb6bb2d176a6
|
4
|
+
data.tar.gz: aeec0549f82ff7bdd68d1aa658ea6ad2033e5310fd5936f40b94007b4ae6c38f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cc7f1da58549b642521808b9ea2acc4b04068bdb7c877cf52943d2ae69bb989f2ade02601b8bfd0e409440ad8644206bba1e6e16603eb56099b8963a2136e350
|
7
|
+
data.tar.gz: 6258250ac5de22034cbb7816d3ff33c62680747a3eaee171fdd784d309bf1cd7880ca60e45342da8ee9195814ef1c78ca9b45b2c435f9fcbc4aaa43a8d7f95e6
|
data/.rubocop.yml
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
AllCops:
|
2
2
|
NewCops: enable
|
3
|
-
TargetRubyVersion: 3.
|
3
|
+
TargetRubyVersion: 3.3
|
4
4
|
|
5
5
|
Style/StringLiterals:
|
6
6
|
EnforcedStyle: double_quotes
|
@@ -44,6 +44,9 @@ Layout/ArrayAlignment:
|
|
44
44
|
Layout/LineLength:
|
45
45
|
Enabled: false
|
46
46
|
|
47
|
+
Layout/LineEndStringConcatenationIndentation:
|
48
|
+
Enabled: false
|
49
|
+
|
47
50
|
RSpec/MultipleMemoizedHelpers:
|
48
51
|
Max: 10
|
49
52
|
|
data/CHANGELOG.md
CHANGED
@@ -6,10 +6,60 @@ All notable changes to this project will be documented in this file.
|
|
6
6
|
|
7
7
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
8
8
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
9
|
-
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.5..HEAD
|
10
9
|
|
11
10
|
<!-- generated by git-cliff end -->
|
12
11
|
|
12
|
+
## [0.1.7] - 2025-04-17
|
13
|
+
|
14
|
+
### 🎨 Refactored
|
15
|
+
|
16
|
+
- Move notification releated classes to new folder by @dieter-medium
|
17
|
+
|
18
|
+
### 🔄 Changed
|
19
|
+
|
20
|
+
- Refactore custom check script in wait_until_page_loaded by @dieter-medium
|
21
|
+
- Add logger support to NetworkEventConsoleFormatter by @dieter-medium
|
22
|
+
- Refactore browser console logger support for enhanced logging by @dieter-medium
|
23
|
+
|
24
|
+
### 🚀 Added
|
25
|
+
|
26
|
+
- Add support for predefined paper formats by @dieter-medium
|
27
|
+
- Add support for websocket-native for performance boost by @dieter-medium
|
28
|
+
- Add logging for print events by @dieter-medium
|
29
|
+
- Add configurable logging and notification service by @dieter-medium
|
30
|
+
- Introduce VerboseLogger for configurable debug levels by @dieter-medium
|
31
|
+
- Instrument Bidi2pdf methods with notification service for enhanced logging by @dieter-medium
|
32
|
+
- Introduce notifications system with event instrumentation and logging compatible to rails by @dieter-medium
|
33
|
+
- Enhance ChromedriverManager with chrome_args and improve session handling by @dieter-medium
|
34
|
+
- Add wait_until_page_loaded method for improved page load handling by @dieter-medium
|
35
|
+
- Enhance Chromedriver process management with platform-specific options by @dieter-medium
|
36
|
+
- Implement BrowserRemoveUserContext command and enhance user context cleanup by @dieter-medium
|
37
|
+
- Add style injection functionality to BrowserTab class by @dieter-medium
|
38
|
+
- Add script injection functionality to BrowserTab class by @dieter-medium
|
39
|
+
|
40
|
+
## [0.1.6] - 2025-04-12
|
41
|
+
|
42
|
+
### ⚠️ Breaking Changes
|
43
|
+
|
44
|
+
- Rename view_html_page to render_html by @dieter-medium
|
45
|
+
- Rename wait_until_all_finished to wait_until_network_idle by @dieter-medium
|
46
|
+
|
47
|
+
### 📝 Docs
|
48
|
+
|
49
|
+
- Update Docker instructions in README by @dieter-medium
|
50
|
+
|
51
|
+
### 🔄 Changed
|
52
|
+
|
53
|
+
- Add details on network logging and console capture by @dieter-medium
|
54
|
+
|
55
|
+
### 🚀 Added
|
56
|
+
|
57
|
+
- Add PDF network log formatting and customizable outputs by @dieter-medium
|
58
|
+
- Add option to log network traffic and handle failures within the cli command by @dieter-medium
|
59
|
+
- Add structured network traffic logging by @dieter-medium
|
60
|
+
- Add slim variant Dockerfile and build matrix for CI by @dieter-medium
|
61
|
+
- Add official image at [Docker Hub](https://hub.docker.com/r/dieters877565/bidi2pdf) by @dieter-medium
|
62
|
+
|
13
63
|
## [0.1.5] - 2025-04-10
|
14
64
|
|
15
65
|
### 📝 Docs
|
@@ -92,3 +142,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
92
142
|
## [0.1.0] - 2025-03-26
|
93
143
|
|
94
144
|
- Initial release
|
145
|
+
|
146
|
+
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.7..HEAD
|
147
|
+
|
148
|
+
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.6..v0.1.7
|
149
|
+
|
150
|
+
[0.1.6]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.5..v0.1.6
|
151
|
+
|
152
|
+
[0.1.5]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.4..v0.1.5
|
153
|
+
|
154
|
+
[0.1.4]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.3..v0.1.4
|
data/README.md
CHANGED
@@ -2,13 +2,14 @@
|
|
2
2
|
[](https://codeclimate.com/github/dieter-medium/bidi2pdf/maintainability)
|
3
3
|
[](https://badge.fury.io/rb/bidi2pdf)
|
4
4
|
[](https://codeclimate.com/github/dieter-medium/bidi2pdf/test_coverage)
|
5
|
+
[](https://www.codetriage.com/dieter-medium/bidi2pdf)
|
5
6
|
|
6
7
|
---
|
7
8
|
|
8
9
|
# 📄 Bidi2pdf – Bulletproof PDF generation via Chrome's BiDi Protocol
|
9
10
|
|
10
|
-
**Bidi2pdf** is a powerful Ruby gem that transforms modern web pages into high-fidelity PDFs using Chrome’s
|
11
|
-
|
11
|
+
**Bidi2pdf** is a powerful Ruby gem that transforms modern web pages into high-fidelity PDFs using Chrome’s
|
12
|
+
**BiDirectional (BiDi)** protocol. Whether you're automating reports, archiving websites, or shipping documentation,
|
12
13
|
Bidi2pdf gives you **precision, flexibility, and full control**.
|
13
14
|
|
14
15
|
---
|
@@ -20,7 +21,9 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
|
|
20
21
|
✅ **Smart waiting** – Wait for complete page load or network idle
|
21
22
|
✅ **Headless support** – Run quietly in the background
|
22
23
|
✅ **Docker-ready** – Plug and play with containers
|
23
|
-
✅ **Modern architecture** – Built on Chrome's next-gen BiDi protocol
|
24
|
+
✅ **Modern architecture** – Built on Chrome's next-gen BiDi protocol
|
25
|
+
✅ **Network logging** – Know which requests fail during rendering
|
26
|
+
✅ **Console log capture** – See what goes wrong inside the browser
|
24
27
|
|
25
28
|
---
|
26
29
|
|
@@ -96,8 +99,8 @@ launcher.launch
|
|
96
99
|
require "bidi2pdf"
|
97
100
|
|
98
101
|
Bidi2pdf::DSL.with_tab(headless: true) do |tab|
|
99
|
-
tab.
|
100
|
-
tab.
|
102
|
+
tab.navigate_to("https://example.com")
|
103
|
+
tab.wait_until_network_idle
|
101
104
|
tab.print("example.pdf")
|
102
105
|
end
|
103
106
|
```
|
@@ -142,17 +145,30 @@ tab.basic_auth(url_patterns: [{ type: "pattern", protocol: "https", hostname: "e
|
|
142
145
|
username: "username", password: "secret")
|
143
146
|
|
144
147
|
# 4. Render PDF
|
145
|
-
tab.
|
148
|
+
tab.navigate_to "https://example.com"
|
146
149
|
|
147
150
|
# Alternative: send html code to the browser
|
148
|
-
# tab.
|
151
|
+
# tab.render_html_content("<html>...</html>")
|
149
152
|
|
150
|
-
|
153
|
+
# Inject JavaScript if, needed
|
154
|
+
# as an url
|
155
|
+
# tab.inject_script "https://example.com/script.js"
|
156
|
+
# or inline
|
157
|
+
# tab.inject_script "console.log('Hello from injected script!')"
|
158
|
+
|
159
|
+
# Inject CSS if needed
|
160
|
+
# as an url
|
161
|
+
# tab.inject_style url: "https://example.com/simple.css"
|
162
|
+
# or inline
|
163
|
+
# tab.inject_style content: "body { background-color: red; }"
|
164
|
+
|
165
|
+
tab.wait_until_network_idle
|
151
166
|
tab.print("my.pdf")
|
152
167
|
|
153
168
|
# 5. Cleanup
|
154
169
|
tab.close
|
155
170
|
window.close
|
171
|
+
context.close
|
156
172
|
session.close
|
157
173
|
```
|
158
174
|
|
@@ -162,15 +178,36 @@ session.close
|
|
162
178
|
|
163
179
|
## 🐳 Docker Support
|
164
180
|
|
165
|
-
### Build & Run
|
181
|
+
### 🛠️ Build & Run Locally
|
166
182
|
|
167
183
|
```bash
|
184
|
+
# Prepare the environment
|
168
185
|
rake build
|
186
|
+
|
187
|
+
# Build the Docker image
|
169
188
|
docker build -t bidi2pdf -f docker/Dockerfile .
|
170
|
-
|
189
|
+
|
190
|
+
# Run the container and generate a PDF
|
191
|
+
docker run -it --rm \
|
192
|
+
-v ./output:/reports \
|
193
|
+
bidi2pdf \
|
194
|
+
bidi2pdf render --url=https://example.com --output /reports/example.pdf
|
195
|
+
|
196
|
+
```
|
197
|
+
|
198
|
+
### ⚡ Use the Prebuilt Image (Recommended for Fast Start)
|
199
|
+
|
200
|
+
Grab it directly from [Docker Hub](https://hub.docker.com/r/dieters877565/bidi2pdf)
|
201
|
+
|
202
|
+
```bash
|
203
|
+
docker run -it --rm \
|
204
|
+
-v ./output:/reports \
|
205
|
+
dieters877565/bidi2pdf:main-slim \
|
171
206
|
bidi2pdf render --url=https://example.com --output /reports/example.pdf
|
172
207
|
```
|
173
208
|
|
209
|
+
✅ Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.
|
210
|
+
|
174
211
|
### Docker Compose
|
175
212
|
|
176
213
|
```bash
|
data/docker/Dockerfile
CHANGED
@@ -1,9 +1,11 @@
|
|
1
1
|
FROM ruby:3.3
|
2
2
|
|
3
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
4
|
+
|
3
5
|
# Install dependencies
|
4
|
-
RUN apt-get update &&
|
5
|
-
apt-get install -y \
|
6
|
-
chromium \
|
6
|
+
RUN apt-get update && apt-get upgrade -y &&\
|
7
|
+
apt-get install -y --no-install-recommends\
|
8
|
+
chromium chromium-driver\
|
7
9
|
libglib2.0-0 \
|
8
10
|
libnss3 \
|
9
11
|
libxss1 \
|
@@ -19,6 +21,12 @@ RUN apt-get update && \
|
|
19
21
|
# Create a non-root user
|
20
22
|
RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser
|
21
23
|
|
24
|
+
# ARM compatibility workaround:
|
25
|
+
# On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors,
|
26
|
+
# such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2".
|
27
|
+
# To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location.
|
28
|
+
|
29
|
+
RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver
|
22
30
|
|
23
31
|
# Set working directory
|
24
32
|
WORKDIR /app
|
@@ -2,9 +2,11 @@ FROM ruby:3.3
|
|
2
2
|
|
3
3
|
ARG CHROMEDRIVER_PORT=3000
|
4
4
|
|
5
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
6
|
+
|
5
7
|
# Install dependencies
|
6
|
-
RUN apt-get update && \
|
7
|
-
apt-get install -y \
|
8
|
+
RUN apt-get update && apt-get upgrade -y && \
|
9
|
+
apt-get install -y --no-install-recommends\
|
8
10
|
chromium \
|
9
11
|
libglib2.0-0 \
|
10
12
|
libnss3 \
|
@@ -0,0 +1,75 @@
|
|
1
|
+
FROM ruby:3.3-slim AS builder
|
2
|
+
|
3
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
4
|
+
|
5
|
+
# Install dependencies
|
6
|
+
RUN apt-get update && apt-get upgrade -y && \
|
7
|
+
apt-get install -y --no-install-recommends \
|
8
|
+
chromium \
|
9
|
+
libglib2.0-0 \
|
10
|
+
libnss3 \
|
11
|
+
libxss1 \
|
12
|
+
libasound2 \
|
13
|
+
libatk-bridge2.0-0 \
|
14
|
+
libgtk-3-0 \
|
15
|
+
libdrm2 \
|
16
|
+
curl \
|
17
|
+
unzip \
|
18
|
+
xvfb \
|
19
|
+
build-essential \
|
20
|
+
libpq-dev pkg-config \
|
21
|
+
&& rm -rf /var/lib/apt/lists/*
|
22
|
+
|
23
|
+
# Set working directory
|
24
|
+
WORKDIR /app
|
25
|
+
|
26
|
+
# Copy your gem into container
|
27
|
+
COPY ./pkg/bidi2pdf-*.gem ./
|
28
|
+
|
29
|
+
RUN gem install ./bidi2pdf-*.gem
|
30
|
+
|
31
|
+
|
32
|
+
# Stage 2
|
33
|
+
|
34
|
+
FROM ruby:3.3-slim
|
35
|
+
|
36
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
37
|
+
|
38
|
+
# Install dependencies
|
39
|
+
RUN apt-get update && apt-get upgrade -y &&\
|
40
|
+
apt-get install -y --no-install-recommends\
|
41
|
+
chromium chromium-driver\
|
42
|
+
libglib2.0-0 \
|
43
|
+
libnss3 \
|
44
|
+
libxss1 \
|
45
|
+
libasound2 \
|
46
|
+
libatk-bridge2.0-0 \
|
47
|
+
libgtk-3-0 \
|
48
|
+
libdrm2 \
|
49
|
+
curl \
|
50
|
+
unzip \
|
51
|
+
xvfb \
|
52
|
+
&& rm -rf /var/lib/apt/lists/*
|
53
|
+
|
54
|
+
COPY --from=builder /usr/local/bundle /usr/local/bundle
|
55
|
+
|
56
|
+
# Create a non-root user
|
57
|
+
RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser
|
58
|
+
|
59
|
+
# ARM compatibility workaround:
|
60
|
+
# On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors,
|
61
|
+
# such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2".
|
62
|
+
# To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location.
|
63
|
+
|
64
|
+
RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver
|
65
|
+
|
66
|
+
# Set working directory
|
67
|
+
WORKDIR /app
|
68
|
+
|
69
|
+
RUN chown -R appuser:appuser /app
|
70
|
+
|
71
|
+
# Switch to non-root user
|
72
|
+
USER appuser
|
73
|
+
|
74
|
+
CMD ["/usr/bin/bash"]
|
75
|
+
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "js_logger_helper"
|
4
|
+
|
5
|
+
module Bidi2pdf
|
6
|
+
module Bidi
|
7
|
+
class BrowserConsoleLoggerSuggar
|
8
|
+
attr_reader :browser_console_logger
|
9
|
+
|
10
|
+
def initialize(browser_console_logger)
|
11
|
+
@browser_console_logger = browser_console_logger
|
12
|
+
end
|
13
|
+
|
14
|
+
def with_level(level)
|
15
|
+
@level = level
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
def with_prefix(prefix)
|
20
|
+
@prefix = prefix
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def with_timestamp(timestamp)
|
25
|
+
@timestamp = timestamp
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def with_text(text)
|
30
|
+
@text = text
|
31
|
+
self
|
32
|
+
end
|
33
|
+
|
34
|
+
def with_args(args)
|
35
|
+
@args = args
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
def with_stack_trace(stack_trace)
|
40
|
+
@stack_trace = stack_trace
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
def log_event
|
45
|
+
browser_console_logger.log_message(@level, @prefix, @text)
|
46
|
+
browser_console_logger.log_args(@prefix, @args)
|
47
|
+
browser_console_logger.log_stack_trace(@prefix, @stack_trace) if @stack_trace && @level == :error
|
48
|
+
end
|
49
|
+
|
50
|
+
def prefix
|
51
|
+
@prefix ||= "[#{BrowserConsoleLogger.format_timestamp(@timestamp)}][Browser Console Log]"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class BrowserConsoleLogger
|
56
|
+
include JsLoggerHelper
|
57
|
+
|
58
|
+
attr_accessor :logger
|
59
|
+
|
60
|
+
def initialize(logger)
|
61
|
+
@logger = logger
|
62
|
+
end
|
63
|
+
|
64
|
+
def builder
|
65
|
+
BrowserConsoleLoggerSuggar.new(self)
|
66
|
+
end
|
67
|
+
|
68
|
+
def log_message(level, prefix, text)
|
69
|
+
return unless text
|
70
|
+
|
71
|
+
logger.send(level, "#{prefix} #{text}")
|
72
|
+
end
|
73
|
+
|
74
|
+
def log_args(prefix, args)
|
75
|
+
return if args.empty?
|
76
|
+
|
77
|
+
logger.debug("#{prefix} Args: #{args.inspect}")
|
78
|
+
end
|
79
|
+
|
80
|
+
def log_stack_trace(prefix, trace)
|
81
|
+
formatted_trace = format_stack_trace(trace)
|
82
|
+
logger.error("#{prefix} Stack trace captured:\n#{formatted_trace}")
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.format_timestamp(timestamp)
|
86
|
+
return "N/A" unless timestamp
|
87
|
+
|
88
|
+
Time.at(timestamp.to_f / 1000).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|