@@ -567,6 +567,8 @@ def starttag(self, quoteattr=None):
567567 if value is None : # boolean attribute
568568 parts .append ('%s="True"' % name )
569569 continue
570+ if isinstance (value , bool ):
571+ value = str (int (value ))
570572 if isinstance (value , list ):
571573 values = [serial_escape ('%s' % (v ,)) for v in value ]
572574 value = ' ' .join (values )
@@ -1093,22 +1095,48 @@ def is_not_known_attribute(cls, attr):
10931095 return attr not in cls .common_attributes
10941096
10951097 def validate_attributes (self ):
1096- # check for undeclared attributes
1097- # TODO: check attribute values
1098+ """Normalize and validate element attributes.
1099+
1100+ Convert string values to expected datatype.
1101+ Normalize values.
1102+
1103+ Raise `ValueError` for invalid attributes or attribute values.
1104+
1105+ Provisional.
1106+ """
1107+ messages = []
10981108 for key , value in self .attributes .items ():
10991109 if key .startswith ('internal:' ):
11001110 continue # see docs/user/config.html#expose-internals
11011111 if key not in self .valid_attributes :
1102- raise ValueError (
1103- f'Element <{ self .tagname } > has invalid attribute "{ key } ".' )
1112+ va = ' ' .join (self .valid_attributes )
1113+ messages .append (f'Attribute "{ key } " not one of "{ va } ".' )
1114+ continue
1115+ try :
1116+ self .attributes [key ] = ATTRIBUTE_VALIDATORS [key ](value )
1117+ except (ValueError , TypeError , KeyError ) as e :
1118+ messages .append (
1119+ f'Attribute "{ key } " has invalid value "{ value } ".\n '
1120+ + e .args [0 ]) # message argument
1121+ if messages :
1122+ raise ValueError ('\n ' .join (messages ))
11041123
11051124 def validate (self ):
1106- # print(f'validating', self.tagname)
1107- self .validate_attributes ()
1125+ messages = []
1126+ try :
1127+ self .validate_attributes ()
1128+ except ValueError as e :
1129+ messages .append (e .args [0 ]) # the message argument
11081130 # TODO: check number of children
11091131 for child in self .children :
11101132 # TODO: check whether child has allowed type
11111133 child .validate ()
1134+ if messages :
1135+ msg = f'Element <{ self .tagname } > invalid:\n ' + '\n ' .join (messages )
1136+ try :
1137+ self .document .reporter .warning (msg )
1138+ except AttributeError :
1139+ raise ValueError (msg )
11121140
11131141
11141142# ========
@@ -2443,6 +2471,229 @@ def serial_escape(value):
24432471 return value .replace ('\\ ' , r'\\' ).replace (' ' , r'\ ' )
24442472
24452473
2474+ def split_name_list (s ):
2475+ r"""Split a string at non-escaped whitespace.
2476+
2477+ Backslashes escape internal whitespace (cf. `serial_escape()`).
2478+ Return list of "names" (after removing escaping backslashes).
2479+
2480+ >>> split_name_list(r'a\ n\ame two\\ n\\ames'),
2481+ ['a name', 'two\\', r'n\ames']
2482+
2483+ Provisional.
2484+ """
2485+ s = s .replace ('\\ ' , '\x00 ' ) # escape with NULL char
2486+ s = s .replace ('\x00 \x00 ' , '\\ ' ) # unescape backslashes
2487+ s = s .replace ('\x00 ' , '\x00 \x00 ' ) # escaped spaces -> NULL NULL
2488+ names = s .split (' ' )
2489+ # restore internal spaces, drop other escaping characters
2490+ return [name .replace ('\x00 \x00 ' , ' ' ).replace ('\x00 ' , '' )
2491+ for name in names ]
2492+
2493+
24462494def pseudo_quoteattr (value ):
24472495 """Quote attributes for pseudo-xml"""
24482496 return '"%s"' % value
2497+
2498+
2499+ # Methods to validate `Element attribute`__ values.
2500+
2501+ # Ensure the expected Python `data type`__, normalize, and check for
2502+ # restrictions.
2503+ #
2504+ # The methods can be used to convert `str` values (eg. from an XML
2505+ # representation) or to validate an existing document tree or node.
2506+ #
2507+ # Cf. `Element.validate_attributes()`, `docutils.parsers.docutils_xml`,
2508+ # and the `attribute_validating_functions` mapping below.
2509+ #
2510+ # __ https://docutils.sourceforge.io/docs/ref/doctree.html#attribute-reference
2511+ # __ https://docutils.sourceforge.io/docs/ref/doctree.html#attribute-types
2512+
2513+ def validate_enumerated_type (* keywords ):
2514+ """
2515+ Return a function that validates a `str` against given `keywords`.
2516+
2517+ Provisional.
2518+ """
2519+ def validate_keywords (value ):
2520+ if value not in keywords :
2521+ allowed = '", \" ' .join (keywords )
2522+ raise ValueError (f'"{ value } " is not one of "{ allowed } ".' )
2523+ return value
2524+ return validate_keywords
2525+
2526+
2527+ def validate_identifier (value ):
2528+ """
2529+ Validate identifier key or class name.
2530+
2531+ Used in `idref.type`__ and for the tokens in `validate_identifier_list()`.
2532+
2533+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#idref-type
2534+
2535+ Provisional.
2536+ """
2537+ if value != make_id (value ):
2538+ raise ValueError (f'"{ value } " is no valid id or class name.' )
2539+ return value
2540+
2541+
2542+ def validate_identifier_list (value ):
2543+ """
2544+ A (space-separated) list of ids or class names.
2545+
2546+ `value` may be a `list` or a `str` with space separated
2547+ ids or class names (cf. `validate_identifier()`).
2548+
2549+ Used in `classnames.type`__, `ids.type`__, and `idrefs.type`__.
2550+
2551+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#classnames-type
2552+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#ids-type
2553+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#idrefs-type
2554+
2555+ Provisional.
2556+ """
2557+ if isinstance (value , str ):
2558+ value = value .split ()
2559+ for token in value :
2560+ validate_identifier (token )
2561+ return value
2562+
2563+
2564+ def validate_measure (value ):
2565+ """
2566+ Validate a length measure__ (number + recognized unit).
2567+
2568+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#measure
2569+
2570+ Provisional.
2571+ """
2572+ units = 'em|ex|px|in|cm|mm|pt|pc|%'
2573+ if not re .fullmatch (f'[-0-9.]+ *({ units } ?)' , value ):
2574+ raise ValueError (f'"{ value } " is no valid measure. '
2575+ f'Valid units: { units .replace ("|" , " " )} .' )
2576+ return value .replace (' ' , '' ).strip ()
2577+
2578+
2579+ def validate_NMTOKEN (value ):
2580+ """
2581+ Validate a "name token": a `str` of letters, digits, and [-._].
2582+
2583+ Provisional.
2584+ """
2585+ if not re .fullmatch ('[-._A-Za-z0-9]+' , value ):
2586+ raise ValueError (f'"{ value } " is no NMTOKEN.' )
2587+ return value
2588+
2589+
2590+ def validate_NMTOKENS (value ):
2591+ """
2592+ Validate a list of "name tokens".
2593+
2594+ Provisional.
2595+ """
2596+ if isinstance (value , str ):
2597+ value = value .split ()
2598+ for token in value :
2599+ validate_NMTOKEN (token )
2600+ return value
2601+
2602+
2603+ def validate_refname_list (value ):
2604+ """
2605+ Validate a list of `reference names`__.
2606+
2607+ Reference names may contain all characters;
2608+ whitespace is normalized (cf, `whitespace_normalize_name()`).
2609+
2610+ `value` may be either a `list` of names or a `str` with
2611+ space separated names (with internal spaces backslash escaped
2612+ and literal backslashes doubled cf. `serial_escape()`).
2613+
2614+ Return a list of whitespace-normalized, unescaped reference names.
2615+
2616+ Provisional.
2617+
2618+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#reference-name
2619+ """
2620+ if isinstance (value , str ):
2621+ value = split_name_list (value )
2622+ return [whitespace_normalize_name (name ) for name in value ]
2623+
2624+
2625+ def validate_yesorno (value ):
2626+ if value == "0" :
2627+ return False
2628+ return bool (value )
2629+
2630+
2631+ ATTRIBUTE_VALIDATORS = {
2632+ 'alt' : str , # CDATA
2633+ 'align' : str ,
2634+ 'anonymous' : validate_yesorno ,
2635+ 'auto' : str , # CDATA (only '1' or '*' are used in rST)
2636+ 'backrefs' : validate_identifier_list ,
2637+ 'bullet' : str , # CDATA (only '-', '+', or '*' are used in rST)
2638+ 'classes' : validate_identifier_list ,
2639+ 'char' : str , # from Exchange Table Model (CALS), currently ignored
2640+ 'charoff' : validate_NMTOKEN , # from CALS, currently ignored
2641+ 'colname' : validate_NMTOKEN , # from CALS, currently ignored
2642+ 'colnum' : int , # from CALS, currently ignored
2643+ 'cols' : int , # from CALS: "NMTOKEN, […] must be an integer > 0".
2644+ 'colsep' : validate_yesorno ,
2645+ 'colwidth' : int , # sic! CALS: CDATA (measure or number+'*')
2646+ 'content' : str , # <meta>
2647+ 'delimiter' : str ,
2648+ 'depth' : int ,
2649+ 'dir' : validate_enumerated_type ('ltr' , 'rtl' , 'auto' ), # <meta>
2650+ 'dupnames' : validate_refname_list ,
2651+ 'enumtype' : validate_enumerated_type ('arabic' , 'loweralpha' , 'lowerroman' ,
2652+ 'upperalpha' , 'upperroman' ),
2653+ 'format' : str , # CDATA (space separated format names)
2654+ 'frame' : validate_enumerated_type ('top' , 'bottom' , 'topbot' , 'all' ,
2655+ 'sides' , 'none' ), # from CALS, ignored
2656+ 'height' : validate_measure ,
2657+ 'http-equiv' : str , # <meta>
2658+ 'ids' : validate_identifier_list ,
2659+ 'lang' : str , # <meta>
2660+ 'level' : int ,
2661+ 'line' : int ,
2662+ 'local' : validate_yesorno ,
2663+ 'ltrim' : validate_yesorno ,
2664+ 'loading' : validate_enumerated_type ('embed' , 'link' , 'lazy' ),
2665+ 'media' : str , # <meta>
2666+ 'morecols' : int ,
2667+ 'morerows' : int ,
2668+ 'name' : whitespace_normalize_name , # in <reference> (deprecated)
2669+ # 'name': node_attributes.validate_NMTOKEN, # in <meta>
2670+ 'names' : validate_refname_list ,
2671+ 'namest' : validate_NMTOKEN , # start of span, from CALS, currently ignored
2672+ 'nameend' : validate_NMTOKEN , # end of span, from CALS, currently ignored
2673+ 'pgwide' : validate_yesorno , # from CALS, currently ignored
2674+ 'prefix' : str ,
2675+ 'refid' : validate_identifier ,
2676+ 'refname' : whitespace_normalize_name ,
2677+ 'refuri' : str ,
2678+ 'rowsep' : validate_yesorno ,
2679+ 'rtrim' : validate_yesorno ,
2680+ 'scale' : int ,
2681+ 'scheme' : str ,
2682+ 'source' : str ,
2683+ 'start' : int ,
2684+ 'stub' : validate_yesorno ,
2685+ 'suffix' : str ,
2686+ 'title' : str ,
2687+ 'type' : validate_NMTOKEN ,
2688+ 'uri' : str ,
2689+ 'valign' : validate_enumerated_type ('top' , 'middle' , 'bottom' ), # from CALS
2690+ 'width' : validate_measure ,
2691+ 'xml:space' : validate_enumerated_type ('default' , 'preserve' ),
2692+ }
2693+ """
2694+ Mapping of `attribute names`__ to validating functions.
2695+
2696+ Provisional.
2697+
2698+ __ https://docutils.sourceforge.io/docs/ref/doctree.html#attribute-reference
2699+ """
0 commit comments