You've already forked comprehensive-rust
mirror of
https://github.com/google/comprehensive-rust.git
synced 2025-06-26 10:41:01 +02:00
Add mdbook-slide-evaluator (#2258)
I created a first implementation for the mdbook-slide-evaluator I described in #2234. One has to run a WebDriver compatible browser (e.g. selenium-chromium) so the slides can be rendered. The browser can access the file either via a file:// or http:// uri. The tool grabs the configurable element from that page and evaluates the size of this element. Output can be stored in a csv file or at stdout and looks like this at the moment: ``` $ mdbook-slide-evaluator book/html/android/aidl book/html/android/aidl/birthday-service.html: 750x134 book/html/android/aidl/example-service/changing-definition.html: 750x555 book/html/android/aidl/example-service/changing-implementation.html: 750x786 book/html/android/aidl/example-service/client.html: 750x1096 book/html/android/aidl/example-service/deploy.html: 750x635 book/html/android/aidl/example-service/interface.html: 750x570 book/html/android/aidl/example-service/server.html: 750x837 book/html/android/aidl/example-service/service-bindings.html: 750x483 book/html/android/aidl/example-service/service.html: 750x711 book/html/android/aidl/types/arrays.html: 750x291 book/html/android/aidl/types/file-descriptor.html: 750x1114 book/html/android/aidl/types/objects.html: 750x1258 book/html/android/aidl/types/parcelables.html: 750x637 book/html/android/aidl/types/primitives.html: 750x366 book/html/android/aidl/types.html: 750x197 ``` --------- Co-authored-by: Martin Geisler <martin@geisler.net>
This commit is contained in:
327
mdbook-slide-evaluator/src/evaluator.rs
Normal file
327
mdbook-slide-evaluator/src/evaluator.rs
Normal file
@ -0,0 +1,327 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use fantoccini::elements::Element;
|
||||
use fantoccini::Client;
|
||||
use log::{debug, warn};
|
||||
use serde::Serialize;
|
||||
use strum::Display;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
|
||||
use crate::slides::{Book, Slide};
|
||||
|
||||
/// An Evaluator is used to render a book that is a collection of slides
|
||||
/// and extract information from an element on that page. It further can
|
||||
/// take a screenshot of this element and store it. A webclient instance is
|
||||
/// created on creation and dropped once the Evaluator is dropped.
|
||||
pub struct Evaluator<'a> {
|
||||
/// webclient used to render html
|
||||
webclient: Client,
|
||||
/// selector for the element that is scored
|
||||
element_selector: fantoccini::wd::Locator<'a>,
|
||||
/// store screenshot in this directory if provided
|
||||
screenshot_dir: Option<PathBuf>,
|
||||
/// html base uri to the source_dir used as a prefix for each page
|
||||
html_base_url: Url,
|
||||
/// base directory for all processed files
|
||||
source_dir: PathBuf,
|
||||
/// if this token is cancelled, the process needs to end gracefully
|
||||
cancellation_token: CancellationToken,
|
||||
/// the policy applied to the slides
|
||||
slide_policy: SlidePolicy,
|
||||
}
|
||||
|
||||
/// element coordinates returned by the browser
|
||||
#[derive(Debug)]
|
||||
struct ElementSize {
|
||||
/// the width of the element
|
||||
width: f64,
|
||||
/// the height of the element
|
||||
height: f64,
|
||||
}
|
||||
|
||||
impl From<(f64, f64, f64, f64)> for ElementSize {
|
||||
fn from(value: (f64, f64, f64, f64)) -> Self {
|
||||
Self { width: value.2, height: value.3 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// holds the evaluation result for a slide
|
||||
pub struct EvaluationResult {
|
||||
/// metadata about the slide
|
||||
slide: Slide,
|
||||
/// the size of the main content element
|
||||
element_size: ElementSize,
|
||||
/// all policy violations
|
||||
policy_violations: Vec<PolicyViolation>,
|
||||
}
|
||||
|
||||
/// holds all evaluation results for a book
|
||||
pub struct EvaluationResults {
|
||||
/// metadata about the book
|
||||
_book: Book,
|
||||
/// the collected evaluation results
|
||||
results: Vec<EvaluationResult>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExportFormat {
|
||||
filename: PathBuf,
|
||||
element_width: usize,
|
||||
element_height: usize,
|
||||
policy_violations: String,
|
||||
}
|
||||
|
||||
impl EvaluationResults {
|
||||
/// export the evaluation results to the given csv file, overwrites if
|
||||
/// allowed
|
||||
pub fn export_csv(
|
||||
&self,
|
||||
file: &Path,
|
||||
overwrite: bool,
|
||||
violations_only: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
if file.exists() && !overwrite {
|
||||
Err(anyhow!(
|
||||
"Not allowed to overwrite existing evaluation results at {}",
|
||||
file.display()
|
||||
))?;
|
||||
};
|
||||
|
||||
let mut csv_writer = csv::Writer::from_path(file)?;
|
||||
for result in &self.results {
|
||||
if violations_only && result.policy_violations.is_empty() {
|
||||
continue;
|
||||
}
|
||||
csv_writer.serialize(ExportFormat {
|
||||
filename: (*result.slide.filename).to_path_buf(),
|
||||
element_width: result.element_size.width.round() as usize,
|
||||
element_height: result.element_size.height.round() as usize,
|
||||
policy_violations: result
|
||||
.policy_violations
|
||||
.iter()
|
||||
.map(PolicyViolation::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(";"),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// dump the results to stdout
|
||||
pub fn export_stdout(&self, violations_only: bool) {
|
||||
for result in &self.results {
|
||||
if violations_only && result.policy_violations.is_empty() {
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
"{}: {}x{} [{}]",
|
||||
result.slide.filename.display(),
|
||||
result.element_size.width,
|
||||
result.element_size.height,
|
||||
result
|
||||
.policy_violations
|
||||
.iter()
|
||||
.map(PolicyViolation::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(";"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Evaluator<'_> {
|
||||
/// create a new instance with the provided config.
|
||||
/// fails if the webclient cannot be created
|
||||
pub fn new(
|
||||
webclient: Client,
|
||||
element_selector: &'a str,
|
||||
screenshot_dir: Option<PathBuf>,
|
||||
html_base_url: Url,
|
||||
source_dir: PathBuf,
|
||||
cancellation_token: CancellationToken,
|
||||
slide_policy: SlidePolicy,
|
||||
) -> Evaluator<'a> {
|
||||
let element_selector = fantoccini::Locator::XPath(element_selector);
|
||||
Evaluator {
|
||||
webclient,
|
||||
element_selector,
|
||||
screenshot_dir,
|
||||
html_base_url,
|
||||
source_dir,
|
||||
cancellation_token,
|
||||
slide_policy,
|
||||
}
|
||||
}
|
||||
|
||||
/// navigate the webdriver to the given url.
|
||||
/// ensure that html_base_url is set before calling this
|
||||
/// after this call the webdriver will see the content at the url
|
||||
async fn webdriver_open_url(&self, url: &Url) -> Result<(), anyhow::Error> {
|
||||
debug!("open url in webclient: {}", url);
|
||||
self.webclient.goto(url.as_str()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// evaluate the currently opened webpage return the selected content
|
||||
/// element if available
|
||||
async fn get_content_element_from_slide(
|
||||
&self,
|
||||
) -> anyhow::Result<Option<Element>> {
|
||||
match self.webclient.find(self.element_selector).await {
|
||||
Result::Ok(result) => Ok(Some(result)),
|
||||
Result::Err(fantoccini::error::CmdError::Standard(
|
||||
fantoccini::error::WebDriver {
|
||||
error: fantoccini::error::ErrorStatus::NoSuchElement,
|
||||
..
|
||||
},
|
||||
)) => anyhow::Ok(None),
|
||||
Result::Err(error) => Err(anyhow!(error))?,
|
||||
}
|
||||
}
|
||||
|
||||
/// extract the element coordinates from this element
|
||||
async fn get_element_coordinates(
|
||||
&self,
|
||||
element: &Element,
|
||||
) -> anyhow::Result<ElementSize> {
|
||||
let coordinates = Into::<ElementSize>::into(element.rectangle().await?);
|
||||
Ok(coordinates)
|
||||
}
|
||||
|
||||
/// store the screenshot as png to the given path
|
||||
fn store_screenshot(
|
||||
&self,
|
||||
screenshot: Vec<u8>,
|
||||
filename: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let relative_filename = filename.strip_prefix(&self.source_dir)?;
|
||||
let output_filename = self
|
||||
.screenshot_dir
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.join(relative_filename.with_extension("png"));
|
||||
debug!("write screenshot to {}", output_filename.to_str().unwrap());
|
||||
|
||||
// create directories if necessary
|
||||
let output_dir = output_filename.parent().unwrap();
|
||||
if !output_dir.exists() {
|
||||
debug!("creating {}", output_dir.to_str().unwrap());
|
||||
fs::create_dir_all(output_dir)?;
|
||||
}
|
||||
|
||||
let mut file =
|
||||
fs::OpenOptions::new().create(true).write(true).open(output_filename)?;
|
||||
|
||||
file.write_all(&screenshot)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// evaluate a single slide
|
||||
pub async fn eval_slide(
|
||||
&self,
|
||||
slide: &Slide,
|
||||
) -> anyhow::Result<Option<EvaluationResult>> {
|
||||
debug!("evaluating {:?}", slide);
|
||||
|
||||
let url = self.html_base_url.join(&slide.filename.display().to_string())?;
|
||||
self.webdriver_open_url(&url).await?;
|
||||
|
||||
let Some(content_element) = self.get_content_element_from_slide().await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let element_size = self.get_element_coordinates(&content_element).await?;
|
||||
if self.screenshot_dir.is_some() {
|
||||
let screenshot = content_element.screenshot().await?;
|
||||
self.store_screenshot(screenshot, &slide.filename)?;
|
||||
}
|
||||
let policy_violations = self.slide_policy.eval_size(&element_size);
|
||||
let result = EvaluationResult {
|
||||
slide: slide.clone(),
|
||||
element_size,
|
||||
policy_violations,
|
||||
};
|
||||
debug!("information about element: {:?}", result);
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
/// evaluate an entire book
|
||||
pub async fn eval_book(&self, book: Book) -> anyhow::Result<EvaluationResults> {
|
||||
let mut results = vec![];
|
||||
debug!("slide count: {}", book.slides().len());
|
||||
for slide in book.slides().iter() {
|
||||
if self.cancellation_token.is_cancelled() {
|
||||
debug!("received cancel request, return already completed results");
|
||||
break;
|
||||
}
|
||||
let Some(result) = self.eval_slide(slide).await? else {
|
||||
warn!("slide with no content - ignore: {:?}", slide);
|
||||
continue;
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
Ok(EvaluationResults { _book: book, results })
|
||||
}
|
||||
}
|
||||
|
||||
/// all possible policy violations
|
||||
#[derive(Debug, Display, Serialize)]
|
||||
enum PolicyViolation {
|
||||
/// violation of the maximum height
|
||||
MaxWidth,
|
||||
/// violation of the maximum width
|
||||
MaxHeight,
|
||||
}
|
||||
|
||||
/// the SlidePolicy struct contains all parameters for evaluating a slide
|
||||
pub struct SlidePolicy {
|
||||
/// the maximum allowed width of a slide
|
||||
pub max_width: usize,
|
||||
/// the maximum allowed height of a slide
|
||||
pub max_height: usize,
|
||||
}
|
||||
|
||||
impl SlidePolicy {
|
||||
/// evaluate if the width is within the policy
|
||||
fn eval_width(&self, element_size: &ElementSize) -> Option<PolicyViolation> {
|
||||
if element_size.width as usize > self.max_width {
|
||||
return Some(PolicyViolation::MaxWidth);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
/// evaluate if the width is within the policy
|
||||
fn eval_height(&self, element_size: &ElementSize) -> Option<PolicyViolation> {
|
||||
if element_size.height as usize > self.max_height {
|
||||
return Some(PolicyViolation::MaxHeight);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
/// evaluate all size policies
|
||||
fn eval_size(&self, element_size: &ElementSize) -> Vec<PolicyViolation> {
|
||||
[self.eval_height(element_size), self.eval_width(element_size)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
}
|
16
mdbook-slide-evaluator/src/lib.rs
Normal file
16
mdbook-slide-evaluator/src/lib.rs
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub mod evaluator;
|
||||
pub mod slides;
|
122
mdbook-slide-evaluator/src/main.rs
Normal file
122
mdbook-slide-evaluator/src/main.rs
Normal file
@ -0,0 +1,122 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use log::{debug, info};
|
||||
use mdbook_slide_evaluator::evaluator::{Evaluator, SlidePolicy};
|
||||
use mdbook_slide_evaluator::slides::Book;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, arg_required_else_help(true))]
|
||||
struct Args {
|
||||
/// the URI of the webdriver
|
||||
#[arg(long, default_value_t=String::from("http://localhost:4444"))]
|
||||
webdriver: String,
|
||||
/// the XPath to element that is evaluated
|
||||
#[arg(long, default_value_t=String::from(r#"//*[@id="content"]/main"#))]
|
||||
element: String,
|
||||
/// take screenshots of the content element if provided
|
||||
#[arg(short, long)]
|
||||
screenshot_dir: Option<PathBuf>,
|
||||
/// a base url that is used to render the files (relative to source_dir).
|
||||
/// if you mount the slides at source_dir into / in a webdriver docker
|
||||
/// container you can use the default
|
||||
#[arg(long, default_value_t=Url::parse("file:///").unwrap())]
|
||||
base_url: Url,
|
||||
/// exports to csv file if provided, otherwise to stdout
|
||||
#[arg(long)]
|
||||
export: Option<PathBuf>,
|
||||
/// allows overwriting the export file
|
||||
#[arg(long, default_value_t = false)]
|
||||
overwrite: bool,
|
||||
/// the height of the webclient that renders the slide
|
||||
#[arg(long, default_value_t = 1920)]
|
||||
webclient_width: u32,
|
||||
/// the width of the webclient that renders the slide
|
||||
#[arg(long, default_value_t = 1080)]
|
||||
webclient_height: u32,
|
||||
/// max width of a slide
|
||||
#[arg(long, default_value_t = 750)]
|
||||
width: usize,
|
||||
/// max height of a slide - default height/width values have 16/9 ratio
|
||||
#[arg(long, default_value_t = 1333)]
|
||||
height: usize,
|
||||
/// if set only violating slides are shown
|
||||
#[arg(long, default_value_t = false)]
|
||||
violations_only: bool,
|
||||
/// directory of the book that is evaluated
|
||||
source_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// pretty env receives log level from RUST_LOG env variable
|
||||
pretty_env_logger::init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// gather information about the book from the filesystem
|
||||
let book = Book::from_html_slides(args.source_dir.clone())?;
|
||||
|
||||
// create a new webclient that is used by the evaluator
|
||||
let webclient: fantoccini::Client =
|
||||
fantoccini::ClientBuilder::native().connect(&args.webdriver).await?;
|
||||
// use a defined window size for reproducible results
|
||||
webclient.set_window_size(args.webclient_width, args.webclient_height).await?;
|
||||
|
||||
let cancellation_token = CancellationToken::new();
|
||||
|
||||
let slide_policy =
|
||||
SlidePolicy { max_width: args.width, max_height: args.height };
|
||||
|
||||
// create a new evaluator (connects to the provided webdriver)
|
||||
let evaluator = Evaluator::new(
|
||||
webclient.clone(),
|
||||
&args.element,
|
||||
args.screenshot_dir,
|
||||
args.base_url,
|
||||
args.source_dir.to_path_buf(),
|
||||
cancellation_token.clone(),
|
||||
slide_policy,
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.unwrap();
|
||||
info!("received CTRL+C");
|
||||
// send a cancel signal
|
||||
cancellation_token.cancel();
|
||||
});
|
||||
|
||||
// evaluate each slide
|
||||
let score_results = evaluator.eval_book(book).await?;
|
||||
|
||||
if let Some(export_file) = args.export {
|
||||
score_results.export_csv(
|
||||
&export_file,
|
||||
args.overwrite,
|
||||
args.violations_only,
|
||||
)?;
|
||||
} else {
|
||||
score_results.export_stdout(args.violations_only);
|
||||
}
|
||||
|
||||
// close webclient as otherwise the unclosed session cannot be reused
|
||||
debug!("closing webclient");
|
||||
webclient.close().await?;
|
||||
Ok(())
|
||||
}
|
54
mdbook-slide-evaluator/src/slides.rs
Normal file
54
mdbook-slide-evaluator/src/slides.rs
Normal file
@ -0,0 +1,54 @@
|
||||
// Copyright 2024 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::debug;
|
||||
|
||||
/// a slide is a page in the book
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Slide {
|
||||
pub filename: Arc<Path>,
|
||||
}
|
||||
|
||||
/// a book is a collection of slides
|
||||
pub struct Book {
|
||||
/// the path to the root directory of this book
|
||||
_source_dir: PathBuf,
|
||||
/// the collection of slides
|
||||
slides: Vec<Slide>,
|
||||
}
|
||||
|
||||
impl Book {
|
||||
/// create a book from all html files in the source_dir
|
||||
pub fn from_html_slides(source_dir: PathBuf) -> anyhow::Result<Book> {
|
||||
let mut slides = vec![];
|
||||
let files = glob::glob(&format!(
|
||||
"{}/**/*.html",
|
||||
source_dir.to_str().expect("invalid path")
|
||||
))?;
|
||||
for file in files {
|
||||
let slide = Slide { filename: file?.into() };
|
||||
debug!("add {:?}", slide);
|
||||
slides.push(slide);
|
||||
}
|
||||
Ok(Book { _source_dir: source_dir, slides })
|
||||
}
|
||||
|
||||
/// return a reference to the slides of this book
|
||||
pub fn slides(&self) -> &[Slide] {
|
||||
&self.slides
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user